diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index e85290b..fb3bbdd 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-11-12T16:09:54.951120+00:00", + "created_at": "2026-11-12T18:07:13.686015+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -758,8 +758,8 @@ }, { "path": "apps/web/src/components/ConversationEarlierRound.test.tsx", - "sha256": "3dd43fd4a224ea81e81e2fda1a9bf4283c16e2742009b3a968dad480a9b8a38f", - "bytes": 5292 + "sha256": "2c99676cf02f3fdff0c6366dfb6e57b1700ee5960dd64ad354a48fa30e8749e8", + "bytes": 7123 }, { "path": "apps/web/src/components/ConversationRunningTurn.test.tsx", @@ -768,8 +768,8 @@ }, { "path": "apps/web/src/components/ConversationTimeline.tsx", - "sha256": "36f5e36c1f60855023b331abc2b04efb36a0e3b390fd4c9c2b389440edee4dbe", - "bytes": 16658 + "sha256": "7443eac32620f29349977b5256291d4ef44d75629efb93f79d6756e625f14627", + "bytes": 16465 }, { "path": "apps/web/src/components/ConversationTimelineAutoRetry.test.tsx", @@ -1288,13 +1288,13 @@ }, { "path": "apps/web/src/components/ResultInterruptionNotice.test.tsx", - "sha256": "a80dd69ffcc1bac7a4975978f98eb5b6a52057cf8ef7f578338a49f41ee3c27d", - "bytes": 9611 + "sha256": "71ec08da5cb0df94d1b837e34b2db9d72a8ed9527c3d1af78707bfff73afe033", + "bytes": 11303 }, { "path": "apps/web/src/components/ResultInterruptionNotice.tsx", - "sha256": "36455548677e3bd8ac4dd24d17214903ad70dc44aea387c986430f57b4b8844d", - "bytes": 5550 + "sha256": "245db7cffb65ea003839800b7e7eb896b42422277864ba3c54390e91d773229a", + "bytes": 5885 }, { "path": "apps/web/src/components/ResultScreen.test.tsx", @@ -2228,8 +2228,8 @@ }, { "path": "apps/web/src/i18n/ja/researchMessagesJa.ts", - "sha256": "c50bbc11e05b034eebb5506c0329666b6a31bbef518016b8dda5bc49ee6e1b7c", - "bytes": 7052 + "sha256": "7eb9cf689adee1fd5dc34e8908933e8526d5ce5a2adaeaae94956b5c75d49fa3", + "bytes": 7113 }, { "path": "apps/web/src/i18n/ja/researchPanelsMessagesJa.ts", @@ -2373,8 +2373,8 @@ }, { "path": "apps/web/src/i18n/researchMessages.ts", - "sha256": "3428986528bdbdb1f6a085be761bd846cec5628e41dfd1e4460d0fc2a0c1a6a1", - "bytes": 12003 + "sha256": "29d9a151d191aa2431b6b25ed9f9a4e27be1654ae7fa9b2e8c9b63e8bfb5fda3", + "bytes": 12108 }, { "path": "apps/web/src/i18n/researchPanelsMessages.ts", @@ -2598,8 +2598,8 @@ }, { "path": "apps/web/src/i18n/zh/researchMessagesZh.ts", - "sha256": "b82d5113235fb2d0015adfacbab655ca8b89c5eb7026d6752cd25a6b23313d7e", - "bytes": 5525 + "sha256": "a1ae0954685a16634ce8e3f19fd1defd9b24dc55f4ac97caf9ea8f065638d7f0", + "bytes": 5574 }, { "path": "apps/web/src/i18n/zh/researchPanelsMessagesZh.ts", @@ -2733,8 +2733,8 @@ }, { "path": "apps/web/src/i18n/zhHant/researchMessagesZhHant.ts", - "sha256": "c9f22cd2000894b76835b2da3d5b2bd4984aa0040a76b51a6e28634293795b80", - "bytes": 5535 + "sha256": "67f14a6a7b984d227ac09430e54928d0396a0ef5b4d0a1b58dca2ad36b98be94", + "bytes": 5584 }, { "path": "apps/web/src/i18n/zhHant/researchPanelsMessagesZhHant.ts", @@ -2988,8 +2988,8 @@ }, { "path": "docs/INSTALL.md", - "sha256": "e5d36424a2b0afa76ae5a434f67247438b7deb54cac23acdaf46ee033cddaf33", - "bytes": 24292 + "sha256": "83273d0e35bf6b863b5672429eb7275073bb9eab6ce8ce0314022d321fce4f87", + "bytes": 24827 }, { "path": "docs/KNOWN_LIMITATIONS.ko.md", @@ -4678,8 +4678,8 @@ }, { "path": "src/thoth/adapters/models/claude_code_tooling.py", - "sha256": "76ea6b71043e54ff26b5363d9b29dae35ae3b31da85699a12824217535f23074", - "bytes": 2837 + "sha256": "2270329bc1a6937fd2f9e5fb8162db9f257851895f4c824809c95dd1af408d61", + "bytes": 5620 }, { "path": "src/thoth/adapters/models/claude_messages.py", @@ -4728,7 +4728,7 @@ }, { "path": "src/thoth/adapters/models/codex_profile.py", - "sha256": "8a7a8e46db2b3ad5bf48adae8c450557492b828dc5fab010c90fe22e8847734f", + "sha256": "6ad0e7e2a111216934696f432666c60ce723834e9cf763419a0f287101f03bbd", "bytes": 21029 }, { @@ -4823,8 +4823,13 @@ }, { "path": "src/thoth/adapters/models/tool_installers.py", - "sha256": "e75bccb88c515cfb595006138dbe192d88399909e867e685ff6e5b3b69c61199", - "bytes": 4163 + "sha256": "c28f3a2be40e6546bf4d5a20719d229d821ebd70af788fb05813d28caca1e691", + "bytes": 4326 + }, + { + "path": "src/thoth/adapters/models/tool_pins.py", + "sha256": "8b5c1883cc87b5dd47f06116679aedff76bf2a7561255af382c409cbaf2e421d", + "bytes": 1069 }, { "path": "src/thoth/adapters/models/transport_diagnostic.py", @@ -5753,8 +5758,8 @@ }, { "path": "src/thoth/application/commands/operations.py", - "sha256": "d7172c275f4280ed2d231c83ff54f9f1f0609fe4a5b8020f9b062c46cfbed5a4", - "bytes": 10547 + "sha256": "02fd4d4856aa675693e28212e87c30803ba58bbc6581805c67e1d8938f386d48", + "bytes": 11098 }, { "path": "src/thoth/application/commands/outcomes_full.py", @@ -6953,8 +6958,8 @@ }, { "path": "src/thoth/application/services/research_controls.py", - "sha256": "cc125d31b07319b85253ba5896548f6a81635daa96fb55a5d3f4128fb5746cfe", - "bytes": 8846 + "sha256": "0ba61764e623d7465bc2e58bfb73d486087858976c1cf722e766a2c108af81b6", + "bytes": 9498 }, { "path": "src/thoth/application/services/research_conversation_activity.py", @@ -7073,8 +7078,8 @@ }, { "path": "src/thoth/application/services/research_read_payload.py", - "sha256": "12b6073adcb5c7c503d1618cb0a57601d99126d5c1880a3062b7cee16122f7ef", - "bytes": 3155 + "sha256": "d54d936048f1e8e237959e40e57ee570a05690eeceb90984ebb2530f5887269f", + "bytes": 3852 }, { "path": "src/thoth/application/services/research_read_summary.py", @@ -7141,6 +7146,11 @@ "sha256": "5f591fe65f6183f08fcb3f2c5d149de2c037785dc185dcd80e5935c7b5765d7a", "bytes": 8932 }, + { + "path": "src/thoth/application/services/research_stop_reason.py", + "sha256": "99b6325cd5c98ec653050e07de7a2dd55524557d820f5a63e3bb375137a86fe5", + "bytes": 1361 + }, { "path": "src/thoth/application/services/research_termination.py", "sha256": "8c4e550c2d90d486a1f3c548ceb300ae72364cb89b82d8a9d2703cc222f2d224", @@ -7153,8 +7163,8 @@ }, { "path": "src/thoth/application/services/research_thread_read_snapshot.py", - "sha256": "34c61bbb9b50d068f0abee7e281da2ea572da338dc7e67528ba434435e640bcf", - "bytes": 8528 + "sha256": "077453bfbbfa7791f9af6cd907bbe35293be771f733818a26d33f0d173ce9d3a", + "bytes": 8809 }, { "path": "src/thoth/application/services/research_usage.py", @@ -7163,8 +7173,8 @@ }, { "path": "src/thoth/application/services/research_usage_read.py", - "sha256": "c549911ba2de7bc9172e750f91d0a7cbe0f393f1afd65fe0595a6521e9488256", - "bytes": 2181 + "sha256": "8c7205bdfdd1ba077b393f6c934952c2f5bf5af9fca81a5a1d33f12b2b746eb7", + "bytes": 2449 }, { "path": "src/thoth/application/services/resource_scope_context.py", @@ -7633,8 +7643,8 @@ }, { "path": "src/thoth/apps/resource_scope_composition.py", - "sha256": "e7cdf98c75bea7ebcb84c020f5ae9dc951a6c46f231213c6ae64726e8120ea5b", - "bytes": 7342 + "sha256": "6b63fb24b155a432e0b906a5621dd8d70b177f39857d8a905cf02cc7e8cc593d", + "bytes": 7515 }, { "path": "src/thoth/apps/restore_profiles.py", @@ -7653,8 +7663,8 @@ }, { "path": "src/thoth/apps/runtime_entry.py", - "sha256": "f2aee07a99332665019c3a04df32ecef9c6a423b2ec5463a3a356764d4562ec9", - "bytes": 7436 + "sha256": "b9bcc0cde407964200f918dd4a34afee253ffe84ae4cc8ffb771bef78a568599", + "bytes": 7582 }, { "path": "src/thoth/apps/runtime_registration.py", @@ -8361,6 +8371,11 @@ "sha256": "558fa878660541fa5721d64566991b31eff4c5f4aff5511d5221f545ac3ad317", "bytes": 2793 }, + { + "path": "src/thoth/domain/research_stop.py", + "sha256": "88ffb95e8a584488dbc79a418d15e2d009e88184c21f15c088e9ce0a1198648e", + "bytes": 1185 + }, { "path": "src/thoth/domain/resource_scope.py", "sha256": "55e2b1b496746068352c571a8b8b24cda3cef1cc09bd683518da488dbdc640b4", @@ -8756,6 +8771,11 @@ "sha256": "1c8c5b0b7f7c74c89b9cdcb19eca1677684b7a67739116612da585ec2401e347", "bytes": 2001 }, + { + "path": "src/thoth/ports/operation_outcome.py", + "sha256": "5e3a80573de21dd7ce75a739a63c7609f2dc0b49292879f74b03bc5b0b3a6d0b", + "bytes": 453 + }, { "path": "src/thoth/ports/outcome.py", "sha256": "739c93bc6e62fb92e2a679b80babde8c8f609e6def7c6b242ec5ae344260ab75", @@ -11498,8 +11518,8 @@ }, { "path": "tests/integration/test_result_usage_read.py", - "sha256": "0741348bc846555cfd21d129eb85070a81bf4a1dd0d1b01999f7defd0f5292e1", - "bytes": 2825 + "sha256": "21bf10b27705af7226aeeed802b0ac0c75f9bd968b44928ff4fe850f9a30fdf7", + "bytes": 3030 }, { "path": "tests/integration/test_resume_interrupted_research.py", @@ -11801,6 +11821,11 @@ "sha256": "b7e23db482eb9130f89a67c28ca0a24431b3ac668000d74addad239a198ac368", "bytes": 2332 }, + { + "path": "tests/integration/test_user_stop_reason.py", + "sha256": "e51a7fe7dade46041d0499f08aafbc7a65258b923d8270f765d5077a4f72bacd", + "bytes": 11277 + }, { "path": "tests/integration/test_v2_cycle_lineage_values.py", "sha256": "7b5656a82f03b863c9e2bcce445385d79aac3450a3615c38ddb2e9d42094c194", @@ -12536,6 +12561,11 @@ "sha256": "e343aff24c329373a3a71fcc05826c089df9f067fe5f01961d15af63e9aab36c", "bytes": 13960 }, + { + "path": "tests/unit/models/test_claude_native_placement.py", + "sha256": "c3b67557caff8ae286f9edabbc7059fc6da097bb2a920a0b4189b523b7c9d4d6", + "bytes": 6691 + }, { "path": "tests/unit/models/test_claude_oauth.py", "sha256": "080992d98b7ce61f0e928b224e22c86fa5101ae6b55ad6349f8e7af01e75bd94", @@ -12703,8 +12733,8 @@ }, { "path": "tests/unit/models/test_tool_installers.py", - "sha256": "cb200125b17e78c8d9c0009cb6ee99052ba706c2c290ed2df1516e2348795296", - "bytes": 6518 + "sha256": "97f85b0d8b823a67b4038ec1ae0185f81e8358bcadcbf6124dba277e8ad02520", + "bytes": 6626 }, { "path": "tests/unit/models/test_transport_diagnostic.py", diff --git a/apps/web/src/components/ConversationEarlierRound.test.tsx b/apps/web/src/components/ConversationEarlierRound.test.tsx index 6af7075..2cf2818 100644 --- a/apps/web/src/components/ConversationEarlierRound.test.tsx +++ b/apps/web/src/components/ConversationEarlierRound.test.tsx @@ -72,7 +72,11 @@ afterEach(async () => { vi.resetAllMocks(); }); -async function mount(liveStatus: ResearchStatus | undefined) { +type Stored = { state: string; result: unknown; error?: unknown }; +async function mount( + liveStatus: ResearchStatus | undefined, + stored: (epoch: number) => Stored = (epoch) => ({ state: "SUCCEEDED", result: result(epoch) }), +) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); vi.mocked(rpc).mockImplementation(async (method, args) => { if (method === "thread/activity/list") @@ -86,7 +90,7 @@ async function mount(liveStatus: ResearchStatus | undefined) { return { operation_id: "", state: "SUCCEEDED", - value: { operation_id: args.operation_id, state: "SUCCEEDED", result: result(epoch) }, + value: { operation_id: args.operation_id, ...stored(epoch) }, }; } throw new Error("not served: " + method); @@ -127,10 +131,32 @@ it("tells an earlier round that its answer checks are not shown here, and the la expect(first).not.toContain(NOT_YET); expect(latest).toContain(NOT_YET); expect(latest).not.toContain(EARLIER); - // The usage of an earlier round is not sent either. Its card does not claim that none was recorded; the latest - // round, whose usage would be sent if there were any, still says so. + // With no usage sent for either round, each card says that none was recorded. + expect(first).toContain(NO_USAGE); + expect(latest).toContain(NO_USAGE); +}); + +it("shows the usage the server recorded for an earlier round, and says none only for a round without any", async () => { + const usage = (operation: string, tokens: number) => ({ + operation_id: operation, + calls: 2, + input_tokens: tokens - 10, + output_tokens: 10, + total_tokens: tokens, + cached_input_tokens: null, + unreported_calls: 0, + state: "OBSERVED", + wall_ms: 120_000, + }); + await mount({ + ...status, + result_usage: { "operation:1": usage("operation:1", 4321) }, + } as ResearchStatus); + const [first, latest] = cards(); + expect(first).toContain("4,321"); expect(first).not.toContain(NO_USAGE); expect(latest).toContain(NO_USAGE); + expect(latest).not.toContain("4,321"); }); it("does not call a round earlier when nothing says which request is the latest", async () => { @@ -141,3 +167,33 @@ it("does not call a round earlier when nothing says which request is the latest" expect(card).not.toContain(EARLIER); } }); + +const STOPPED = "직접 중지했습니다."; +const CANCEL_UNKNOWN = "취소 사유는 현재 기록에서 확인되지 않습니다."; + +it("tells the turn of a round the person stopped that they stopped it, from operation/result/read alone", async () => { + await mount(undefined, (epoch) => + epoch === 1 + ? { + state: "CANCELLED", + result: null, + error: { message: "USER_STOPPED", data: { reason_code: "USER_STOPPED" } }, + } + : { state: "SUCCEEDED", result: result(epoch) }, + ); + const [first, latest] = cards(); + expect(first).toContain(STOPPED); + expect(first).not.toContain(CANCEL_UNKNOWN); + expect(latest).not.toContain(STOPPED); +}); + +it("keeps the old sentence for a cancelled turn that has no reason on record", async () => { + await mount(undefined, (epoch) => + epoch === 1 + ? { state: "CANCELLED", result: null, error: null } + : { state: "SUCCEEDED", result: result(epoch) }, + ); + const [first] = cards(); + expect(first).toContain(CANCEL_UNKNOWN); + expect(first).not.toContain(STOPPED); +}); diff --git a/apps/web/src/components/ConversationTimeline.tsx b/apps/web/src/components/ConversationTimeline.tsx index 5402c38..5ebfbb9 100644 --- a/apps/web/src/components/ConversationTimeline.tsx +++ b/apps/web/src/components/ConversationTimeline.tsx @@ -221,13 +221,9 @@ function SavedTurnCard({ }) { const requestDigest = turn.input.request_revision_digest; const earlier = isEarlierRound(liveStatus, requestDigest); - // The server sends the usage of the latest result and of the request now running only. An earlier round has - // none here, which is not the same as "no usage recorded": its line is left out (the conversation's total is - // under the question box). - const usageLine = - earlier && !liveStatus?.result_usage?.[turn.input.operation_id] - ? undefined - : resultUsageLine(liveStatus, turn.input.operation_id); + // The server sends the usage of every round that recorded a call, the earlier ones included. A round with none says + // so; it is no longer left out. + const usageLine = resultUsageLine(liveStatus, turn.input.operation_id); return ( { } expect(seen.size).toBe(5); }); + +/** A research run that ended CANCELLED, with the reason code the server recorded (none for an older record). */ +function cancelledNotice(code: string | null): string { + const view = interruptionView({ + state: "CANCELLED", + result: null, + error: code === null ? null : { message: code, data: { reason_code: code } }, + }); + return renderToStaticMarkup( + {}} + />, + ); +} + +it("says the person stopped it, in the five languages", () => { + const words = { + ko: "직접 중지했습니다.", + en: "You stopped it yourself.", + "zh-Hans": "你已手动停止。", + "zh-Hant": "你已手動停止。", + ja: "ご自身で停止しました。", + } as const; + for (const [locale, sentence] of Object.entries(words)) { + setLocale(locale as keyof typeof words); + const html = cancelledNotice("USER_STOPPED"); + expect(html).toContain(sentence); + expect(html).not.toContain(t("result.cancelUnknown")); + expect(html).not.toContain("USER_STOPPED"); + } +}); + +it("keeps today's sentence for a cancelled run with no reason on record", () => { + const html = cancelledNotice(null); + expect(html).toContain(t("result.cancelUnknown")); + expect(html).not.toContain("직접 중지했습니다."); +}); + +it("still shows another recorded reason as the recorded code, never as the person's stop", () => { + const html = cancelledNotice("RESEARCH_TIME_BUDGET_EXHAUSTED"); + expect(html).toContain("RESEARCH_TIME_BUDGET_EXHAUSTED"); + expect(html).not.toContain("직접 중지했습니다."); + expect(html).not.toContain(t("result.cancelUnknown")); +}); diff --git a/apps/web/src/components/ResultInterruptionNotice.tsx b/apps/web/src/components/ResultInterruptionNotice.tsx index b44f45a..202724a 100644 --- a/apps/web/src/components/ResultInterruptionNotice.tsx +++ b/apps/web/src/components/ResultInterruptionNotice.tsx @@ -11,6 +11,14 @@ import type { InterruptionView } from "./resultInterruption"; const AUTH_LIKE = /AUTH_REQUIRED|OAUTH_REQUEST_REJECTED|MODEL_NOT_SUPPORTED|CLAUDE_CODE_LOGIN_REQUIRED/; const TIME_LIKE = /TIME_BUDGET|DEADLINE/; +/** What the server records when the person pressed stop. */ +const USER_STOPPED = "USER_STOPPED"; + +/** Why a cancelled run ended: the person's own stop, a recorded reason, or none on record. */ +function cancelReasonText(code: string): string { + if (code === USER_STOPPED) return t("result.userStopped"); + return code ? message("terminationReason", code) : t("result.cancelUnknown"); +} /** The sentences that explain an interrupted run, in display order; each is shown only when its code matches. */ function interruptionReasons({ @@ -139,7 +147,7 @@ export function ResultInterruptionNotice({ return (

{t("result.cancelled")}

-

{view.code ? message("terminationReason", view.code) : t("result.cancelUnknown")}

+

{cancelReasonText(view.code)}

{view.content ? ( {t("result.beforeCancel")} ) : ( diff --git a/apps/web/src/i18n/ja/researchMessagesJa.ts b/apps/web/src/i18n/ja/researchMessagesJa.ts index 03c7db5..7b4999c 100644 --- a/apps/web/src/i18n/ja/researchMessagesJa.ts +++ b/apps/web/src/i18n/ja/researchMessagesJa.ts @@ -29,6 +29,7 @@ export const researchJa: ColumnOf = { "result.deadline": "呼び出しの期限に達しました", "result.responseTimeout": "応答がタイムアウトしました", "result.cancelled": "調査はキャンセルされました。自動では再開しません。", + "result.userStopped": "ご自身で停止しました。", "result.cancelUnknown": "キャンセルの理由は現在の記録にありません。", "result.beforeCancel": "キャンセル前に保存された調査結果", "result.noSavedAnswer": "このリクエストに保存された回答はありません。", diff --git a/apps/web/src/i18n/researchMessages.ts b/apps/web/src/i18n/researchMessages.ts index 71fe3c0..816f841 100644 --- a/apps/web/src/i18n/researchMessages.ts +++ b/apps/web/src/i18n/researchMessages.ts @@ -87,6 +87,10 @@ export const researchMessages = { ko: "연구 실행이 취소되었습니다. 자동 재실행하지 않습니다.", en: "Research was cancelled. It will not restart automatically.", }, + "result.userStopped": { + ko: "직접 중지했습니다.", + en: "You stopped it yourself.", + }, "result.cancelUnknown": { ko: "취소 사유는 현재 기록에서 확인되지 않습니다.", en: "The cancellation reason is not available in the current record.", diff --git a/apps/web/src/i18n/zh/researchMessagesZh.ts b/apps/web/src/i18n/zh/researchMessagesZh.ts index 5598cf4..fdc3174 100644 --- a/apps/web/src/i18n/zh/researchMessagesZh.ts +++ b/apps/web/src/i18n/zh/researchMessagesZh.ts @@ -24,6 +24,7 @@ export const researchZh: ColumnOf = { "result.deadline": "已到调用期限", "result.responseTimeout": "响应超时", "result.cancelled": "研究已取消。不会自动重新开始。", + "result.userStopped": "你已手动停止。", "result.cancelUnknown": "当前记录中没有取消的原因。", "result.beforeCancel": "取消前已保存的发现", "result.noSavedAnswer": "此请求没有已保存的回答。", diff --git a/apps/web/src/i18n/zhHant/researchMessagesZhHant.ts b/apps/web/src/i18n/zhHant/researchMessagesZhHant.ts index 8372b8b..6db8ce8 100644 --- a/apps/web/src/i18n/zhHant/researchMessagesZhHant.ts +++ b/apps/web/src/i18n/zhHant/researchMessagesZhHant.ts @@ -24,6 +24,7 @@ export const researchZhHant: ColumnOf = { "result.deadline": "已到呼叫期限", "result.responseTimeout": "回應超時", "result.cancelled": "研究已取消。不會自動重新開始。", + "result.userStopped": "你已手動停止。", "result.cancelUnknown": "目前記錄中沒有取消的原因。", "result.beforeCancel": "取消前已儲存的發現", "result.noSavedAnswer": "此請求沒有已儲存的回答。", diff --git a/docs/INSTALL.md b/docs/INSTALL.md index dbcab22..455b790 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -164,8 +164,11 @@ are not imported. A configured connection and a successful provider response are The Codex CLI and Claude Code are helper programs. When one is missing, its card offers an automatic install (Node.js required) into `%LOCALAPPDATA%\THOTH\tools\codex` or `...\claude-code`. It installs one -pinned version, checks it, and never touches a global install or another program's login. The -manual commands below do the same thing. +pinned version with `npm --ignore-scripts` (no install script of the package or its dependencies runs), +checks the integrity npm recorded against the values pinned in THOTH, and never touches a global +install or another program's login. For Claude Code, THOTH then copies the platform package's +`claude.exe` over the `bin\claude.exe` placeholder itself, only when both integrity values match; +otherwise the install fails and nothing is copied. For Codex, use THOTH's connection action. A started login is not a completed login; refresh the connection status after finishing in the browser. Missing CLI, unsupported version, missing login, @@ -199,7 +202,7 @@ Use a separate tool directory so installing it does not replace your global CLI ```powershell $thothCodexPrefix = Join-Path $env:LOCALAPPDATA 'THOTH\tools\codex' -npm.cmd install --prefix $thothCodexPrefix --install-strategy=nested --save-exact @openai/codex@0.157.1 +npm.cmd install --prefix $thothCodexPrefix --install-strategy=nested --save-exact --ignore-scripts @openai/codex@0.157.1 $env:THOTH_CODEX_PACKAGE_ROOT = Join-Path $thothCodexPrefix 'node_modules\@openai\codex' .\.venv\Scripts\python.exe -m thoth.cli serve --port 8765 ``` @@ -224,7 +227,9 @@ native version check passed; product authentication and integrated acceptance ar THOTH looks for Claude Code in its own tools folder first, then `claude` on PATH (an npm shim is followed to its native `claude.exe`), then the official installer folder. A global Claude Code that other programs use therefore never overrides the copy THOTH installed. To install a THOTH-only copy -without changing that global one: +without changing that global one, use the install button on the Claude card. A manual install needs +the package's own install script, because `--ignore-scripts` leaves a 500-byte placeholder in +`bin\claude.exe`; running it trusts that script: ```powershell $thothClaudePrefix = Join-Path $env:LOCALAPPDATA 'THOTH\tools\claude-code' diff --git a/src/thoth/adapters/models/claude_code_tooling.py b/src/thoth/adapters/models/claude_code_tooling.py index bdbce02..9c9afd0 100644 --- a/src/thoth/adapters/models/claude_code_tooling.py +++ b/src/thoth/adapters/models/claude_code_tooling.py @@ -2,9 +2,13 @@ from __future__ import annotations +import json import os +import shutil from collections.abc import Callable, Mapping from pathlib import Path +from typing import cast +from uuid import uuid4 from thoth.adapters.models.claude_code import ( ClaudeCodeUnavailable, @@ -19,10 +23,65 @@ run_npm, tools_prefix, ) +from thoth.adapters.models.tool_pins import ( + CLAUDE_CODE_PLATFORM_INTEGRITY, + CLAUDE_CODE_VERSION, + CLAUDE_CODE_WRAPPER_INTEGRITY, +) from thoth.ports.model_tooling import ModelToolingError # The version this route was checked against; the executable's own minimum is enforced separately. -CLAUDE_CODE_PACKAGE_SPEC = "@anthropic-ai/claude-code@2.1.284" +CLAUDE_CODE_PACKAGE_SPEC = f"@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}" + +_FAILED = "CLAUDE_CODE_TOOL_INSTALL_FAILED" +_WRAPPER_KEY = "node_modules/@anthropic-ai/claude-code" +# npm nests the platform package under the wrapper (the install strategy), or puts it beside it. +_PLATFORM_KEYS = ( + f"{_WRAPPER_KEY}/node_modules/@anthropic-ai/claude-code-win32-x64", + "node_modules/@anthropic-ai/claude-code-win32-x64", +) + + +def _integrity(packages: dict[str, object], key: str) -> object: + entry = packages.get(key) + return cast(dict[str, object], entry).get("integrity") if isinstance(entry, dict) else None + + +def place_native_binary(prefix: Path) -> None: + """Put the native binary where THOTH runs it, after an install that ran no script. + + The package's own install script only copies the platform package's claude.exe over the + bin/claude.exe placeholder. THOTH does that copy itself, and only when npm recorded exactly the + pinned integrity for both the wrapper and the platform package. Anything else (no record, a + different value, no binary) fails the install and leaves bin/claude.exe as it was. The copy is + written beside the target under another name and then swapped in. + """ + try: + lock = json.loads((prefix / "node_modules" / ".package-lock.json").read_text("utf-8")) + recorded = lock.get("packages") + except (OSError, ValueError, AttributeError) as exc: + raise ModelToolingError(_FAILED) from exc + if not isinstance(recorded, dict): + raise ModelToolingError(_FAILED) + packages = cast(dict[str, object], recorded) + platform_key = next((key for key in _PLATFORM_KEYS if key in packages), None) + if ( + platform_key is None + or _integrity(packages, _WRAPPER_KEY) != CLAUDE_CODE_WRAPPER_INTEGRITY + or _integrity(packages, platform_key) != CLAUDE_CODE_PLATFORM_INTEGRITY + ): + raise ModelToolingError(_FAILED) + source = prefix / platform_key / "claude.exe" + target = prefix / _WRAPPER_KEY / "bin" / "claude.exe" + if source.is_symlink() or not source.is_file() or not target.parent.is_dir(): + raise ModelToolingError(_FAILED) + staged = target.with_name(f"claude.exe.{uuid4().hex}.tmp") + try: + shutil.copyfile(source, staged) + os.replace(staged, target) + except OSError as exc: + staged.unlink(missing_ok=True) + raise ModelToolingError(_FAILED) from exc def install_pinned_claude_code( @@ -49,10 +108,11 @@ def __init__( env: Mapping[str, str] | None = None, verify: Callable[[], dict[str, object]] | None = None, invalidate: Callable[[Path | None], None] = invalidate_claude_code_status, + place: Callable[[Path], None] = place_native_binary, ) -> None: self._workspace, self._runner, self._npm = workspace, runner, npm self._prefix, self._env, self._verify = prefix, env, verify - self._invalidate = invalidate + self._invalidate, self._place = invalidate, place def _check(self) -> dict[str, object]: if self._verify is not None: @@ -68,9 +128,10 @@ def _check(self) -> dict[str, object]: return claude_code_status(self._workspace, executable=executable) def install(self) -> dict[str, object]: - install_pinned_claude_code( - self._prefix or tools_prefix("claude-code", self._env), self._runner, npm=self._npm - ) + prefix = self._prefix or tools_prefix("claude-code", self._env) + install_pinned_claude_code(prefix, self._runner, npm=self._npm) + # The install ran no script, so the native binary is put in place here, after the check. + self._place(prefix) self._invalidate(self._workspace) status = self._check() if status.get("connection_state") == "UNAVAILABLE": diff --git a/src/thoth/adapters/models/codex_profile.py b/src/thoth/adapters/models/codex_profile.py index fa80704..10b213f 100644 --- a/src/thoth/adapters/models/codex_profile.py +++ b/src/thoth/adapters/models/codex_profile.py @@ -19,24 +19,24 @@ from uuid import uuid4 from thoth.adapters.models.async_file_lock import async_file_lock +from thoth.adapters.models.tool_pins import ( + CODEX_PACKAGE_VERSION, + CODEX_PLATFORM_INTEGRITY, + CODEX_PLATFORM_VERSION, + CODEX_WRAPPER_INTEGRITY, +) from thoth.ports.model import ModelExecutionHold PINNED_VERSION = "codex-cli 0.157.1" -PINNED_PACKAGE_VERSION = "0.157.1" -PINNED_PLATFORM_VERSION = "0.157.1-win32-x64" +PINNED_PACKAGE_VERSION = CODEX_PACKAGE_VERSION +PINNED_PLATFORM_VERSION = CODEX_PLATFORM_VERSION PINNED_SOURCE_COMMIT = "36650394c5b38c2990ccf2a3457165ca3e9d9726" APP_SERVER_COMMON_SOURCE_SHA256 = "27fe9ee0a155ee1e5f59860869277fd7179f22e151a38326738396b60d97ebea" APP_SERVER_ACCOUNT_SOURCE_SHA256 = ( "f52c03c9f98f8fd496824616ec1669954f35470cbd2ff15101ceb4b7e6a57b2ad" ) -WRAPPER_INTEGRITY = ( - "sha512-qJ/UZ0bmYP+/Umav1L9WpmtMYeA6q1+4r4qILSYOKQZhP7WRdjy" - "TQWz3O0dTImZ9RT7AazZa85D87xRDHogcHw==" -) -PLATFORM_INTEGRITY = ( - "sha512-vgqs/VRXNwhLYMsZDgYfnRSXpRh5Nm782L8lacGskw86kOxbMaquvQKxku" - "ZHUBrJA2XGcksB7rMUHy1XaCJgrA==" -) +WRAPPER_INTEGRITY = CODEX_WRAPPER_INTEGRITY +PLATFORM_INTEGRITY = CODEX_PLATFORM_INTEGRITY NATIVE_RELATIVE = Path("vendor/x86_64-pc-windows-msvc/bin/codex.exe") AUTH_READER_VERSION = "AuthDotJson-TokenData-v1" CODEX_BACKEND_ORIGIN = "https://chatgpt.com" diff --git a/src/thoth/adapters/models/tool_installers.py b/src/thoth/adapters/models/tool_installers.py index d922be1..6c5d0ce 100644 --- a/src/thoth/adapters/models/tool_installers.py +++ b/src/thoth/adapters/models/tool_installers.py @@ -60,7 +60,10 @@ def install_npm_package( npm: str | None = None, timeout: float = NPM_INSTALL_TIMEOUT_SECONDS, ) -> None: - """One exact-version npm install into `prefix`; outcome is a typed reason, not npm output.""" + """One exact-version npm install into `prefix` that runs no package script. + + The outcome is a typed reason, not npm output. + """ executable = npm or find_npm() if not executable: raise ModelToolingError("NODE_NPM_UNAVAILABLE") @@ -71,6 +74,8 @@ def install_npm_package( str(prefix), "--install-strategy=nested", "--save-exact", + # No install script of the package or of anything it pulls in runs on this computer. + "--ignore-scripts", package_spec, ) try: diff --git a/src/thoth/adapters/models/tool_pins.py b/src/thoth/adapters/models/tool_pins.py new file mode 100644 index 0000000..e78204e --- /dev/null +++ b/src/thoth/adapters/models/tool_pins.py @@ -0,0 +1,27 @@ +"""The exact versions and package integrity values of the tools THOTH installs, in one place. + +THOTH installs these packages with npm, runs none of their install scripts, and accepts them only +when the integrity npm recorded for the installed packages is the value written here. To move to a +new version, change the values here together and check the result with the install tests. +""" + +CODEX_PACKAGE_VERSION = "0.157.1" +CODEX_PLATFORM_VERSION = "0.157.1-win32-x64" +CODEX_WRAPPER_INTEGRITY = ( + "sha512-qJ/UZ0bmYP+/Umav1L9WpmtMYeA6q1+4r4qILSYOKQZhP7WRdjy" + "TQWz3O0dTImZ9RT7AazZa85D87xRDHogcHw==" +) +CODEX_PLATFORM_INTEGRITY = ( + "sha512-vgqs/VRXNwhLYMsZDgYfnRSXpRh5Nm782L8lacGskw86kOxbMaquvQKxku" + "ZHUBrJA2XGcksB7rMUHy1XaCJgrA==" +) + +CLAUDE_CODE_VERSION = "2.1.284" +CLAUDE_CODE_WRAPPER_INTEGRITY = ( + "sha512-IuENsoLa+Y5fx5VaP0Md3n5UO7aYxjbFE/iydSDw6tMo2171oaxaaBa9" + "oIepPG9NILd1owSx74ozsqAkTbEOjw==" +) +CLAUDE_CODE_PLATFORM_INTEGRITY = ( + "sha512-Usw9Z8unwN6sWlhAJm5pBdTj5LoYBotUddhCBpnnH1XFJT9dgxRvRXqhftuVQi6LhorFo/" + "HD0VfeHmmJFNCMaw==" +) diff --git a/src/thoth/application/commands/operations.py b/src/thoth/application/commands/operations.py index 344c235..71a30ba 100644 --- a/src/thoth/application/commands/operations.py +++ b/src/thoth/application/commands/operations.py @@ -16,6 +16,7 @@ from thoth.ports.event_store import EventStorePort from thoth.ports.ledger import LedgerPort from thoth.ports.operation import OperationStorePort +from thoth.ports.operation_outcome import OperationOutcomeReasonPort from thoth.ports.resource_scope import ResourceAccessPort from thoth.ports.runtime import ClockPort from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode @@ -46,7 +47,9 @@ def __init__( clock: ClockPort | None = None, resource_access: ResourceAccessPort | None = None, read_ledger: LedgerPort | None = None, + outcome_reasons: OperationOutcomeReasonPort | None = None, ) -> None: + self._outcome_reasons = outcome_reasons self._store = store self._events = events self._clock = clock @@ -89,6 +92,12 @@ async def read(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: ) return payload + def _shown_error(self, operation: OperationRecord) -> dict[str, JsonValue] | None: + """The stored error, or the reason an operation that knows its own gives for its ending.""" + if operation.error is not None or self._outcome_reasons is None: + return operation.error + return self._outcome_reasons.error_for(operation) + async def result(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: request = OperationResultReadInput.model_validate(value) operation = self._read_query_scoped(request) @@ -97,7 +106,7 @@ async def result(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: "operation_id": operation.operation_id, "state": operation.state.value, "result": operation.result, - "error": operation.error, + "error": self._shown_error(operation), "execution_observation": "UNKNOWN", }, request.expected_result_contract, diff --git a/src/thoth/application/services/research_controls.py b/src/thoth/application/services/research_controls.py index 54bbc6a..b02a684 100644 --- a/src/thoth/application/services/research_controls.py +++ b/src/thoth/application/services/research_controls.py @@ -15,6 +15,7 @@ ResearchAttempt, decode_thread_request, ) +from thoth.domain.research_stop import ResearchStopNote, stop_note_key from thoth.protocol.deferred import AcceptedRunning from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode @@ -173,9 +174,19 @@ async def stop(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: # Stopped thread, request still running (a first stop was cut off before it # cancelled): finish that cancel now, as a normal stop. del result["unchanged"], result["unchanged_reason"] - self.host.operations.cancel( - operation.operation_id, completed_at=self.host.records.clock.now() - ) + now = self.host.records.clock.now() + cancelled = self.host.operations.cancel(operation.operation_id, completed_at=now) + if cancelled.state == OperationState.CANCELLED and cancelled.completed_at == now: + # This stop is what ended the request (a cancel that had already happened keeps its + # own time): say so. + self.host.records.journal( + operation.project_id, + stop_note_key(operation.operation_id), + ResearchStopNote( + operation_id=operation.operation_id, requested_at=now.isoformat() + ), + "CANCELLED", + ) self.host.records.journal( operation.project_id, operation.operation_id, diff --git a/src/thoth/application/services/research_read_payload.py b/src/thoth/application/services/research_read_payload.py index 608d6ad..6156efe 100644 --- a/src/thoth/application/services/research_read_payload.py +++ b/src/thoth/application/services/research_read_payload.py @@ -7,6 +7,8 @@ from pydantic import JsonValue from thoth.application.services.research_model_hold import model_call_hold_failure +from thoth.domain.enums import OperationState +from thoth.domain.research_stop import user_stopped_error if TYPE_CHECKING: from thoth.application.services.research_thread_read_snapshot import ResearchThreadReadSnapshot @@ -14,6 +16,20 @@ from thoth.domain.research_failure import ResearchCleanupFailure, ResearchFailureRecord +def operation_error(snapshot: ResearchThreadReadSnapshot) -> JsonValue: + """The operation's error; for a request the person stopped, that fact in the same shape. + + A cancelled operation has no stored error. The reason is read from the stop note, and only when + the operation did end CANCELLED: a request that finished first keeps no reason. + """ + op, note = snapshot.operation, snapshot.stop_note + if op is None: + return None + if op.error is None and note is not None and op.state == OperationState.CANCELLED: + return user_stopped_error(note) + return op.error + + def snapshot_fields( snapshot: ResearchThreadReadSnapshot, base: dict[str, JsonValue], @@ -35,7 +51,7 @@ def snapshot_fields( "request_ref": current_ref.model_dump(mode="json"), "request_epoch": request.request_epoch, "operation_state": "UNKNOWN" if op is None else op.state.value, - "operation_error": None if op is None else op.error, + "operation_error": operation_error(snapshot), "failure": model_call_hold_failure(manifest if fresh else None, attempt) if failure is None else failure.model_dump(mode="json"), diff --git a/src/thoth/application/services/research_stop_reason.py b/src/thoth/application/services/research_stop_reason.py new file mode 100644 index 0000000..21f28d0 --- /dev/null +++ b/src/thoth/application/services/research_stop_reason.py @@ -0,0 +1,35 @@ +"""The reason a cancelled research request ended, read from the control journal for any reader. + +A general operation reader (operation/result/read) must not know the research journal. It is given +this object and asks it, for an operation that ended CANCELLED with no stored error, whether the +person stopped the request. Every other operation gets no answer, so its reply stays as it was. +""" + +from pydantic import JsonValue + +from thoth.domain.enums import OperationState +from thoth.domain.operation import OperationRecord +from thoth.domain.research_stop import ( + RESEARCH_EXECUTION_NAMESPACE, + ResearchStopNote, + stop_note_key, + user_stopped_error, +) +from thoth.ports.control_record import ControlRecordStorePort + + +class ResearchStopReasons: + def __init__(self, controls: ControlRecordStorePort) -> None: + self._controls = controls + + def error_for(self, operation: OperationRecord) -> dict[str, JsonValue] | None: + if operation.state != OperationState.CANCELLED or operation.error is not None: + return None + record = self._controls.read( + operation.project_id, + RESEARCH_EXECUTION_NAMESPACE, + stop_note_key(operation.operation_id), + ) + if record is None: + return None + return user_stopped_error(ResearchStopNote.model_validate(record.payload)) diff --git a/src/thoth/application/services/research_thread_read_snapshot.py b/src/thoth/application/services/research_thread_read_snapshot.py index 14ce291..fd23865 100644 --- a/src/thoth/application/services/research_thread_read_snapshot.py +++ b/src/thoth/application/services/research_thread_read_snapshot.py @@ -32,6 +32,7 @@ ThreadRequestRevision, decode_thread_request, ) +from thoth.domain.research_stop import ResearchStopNote, stop_note_key from thoth.ports.source_version_catalog import SourceVersionCatalogPort if TYPE_CHECKING: @@ -51,6 +52,7 @@ class ResearchThreadReadSnapshot: dispatches: tuple[ModelDispatchRecord, ...] activity_source_context: dict[str, dict[str, object]] operation: OperationRecord | None + stop_note: ResearchStopNote | None = None def read_research_snapshot( @@ -143,6 +145,9 @@ def read_research_snapshot( currentness = boundary_currentness(currentness, fresh) fresh = fresh and currentness.state == "CURRENT" budget = host.records.journal_read(project_id, f"budget:{thread_id}", ResearchBudget) + stop_note = host.records.journal_read( + project_id, stop_note_key(request.operation_id), ResearchStopNote + ) dispatches: list[ModelDispatchRecord] = [] try: listed = host.records.controls.list( @@ -187,4 +192,5 @@ def read_research_snapshot( dispatches=tuple(dispatches), activity_source_context=activity_source_context, operation=op, + stop_note=stop_note, ) diff --git a/src/thoth/application/services/research_usage_read.py b/src/thoth/application/services/research_usage_read.py index 121715b..e5391d3 100644 --- a/src/thoth/application/services/research_usage_read.py +++ b/src/thoth/application/services/research_usage_read.py @@ -38,12 +38,18 @@ def read_thread_usage( quota if quota is not None else provider_usage.read_snapshot(project_id, thread_id) ).model_dump(mode="json"), ) - # Per-result usage, computed from this read's dispatches: the result's own operation and the - # one currently requested. A result without recorded calls simply has no entry. + # Per-result usage, computed from this read's dispatches: the result's own operation, the one + # currently requested, then every earlier round that recorded a call (they are already read: + # the dispatches of the whole thread are loaded for the thread totals above). A round without + # recorded calls simply has no entry. result_usage: dict[str, JsonValue] = {} for usage_op in dict.fromkeys( op_id - for op_id in (None if manifest is None else manifest.operation_id, request.operation_id) + for op_id in ( + None if manifest is None else manifest.operation_id, + request.operation_id, + *(d.operation_id for d in dispatches if d.thread_id == thread_id), + ) if op_id ): usage_record = operations.read(usage_op) diff --git a/src/thoth/apps/resource_scope_composition.py b/src/thoth/apps/resource_scope_composition.py index 7754c73..9ab004f 100644 --- a/src/thoth/apps/resource_scope_composition.py +++ b/src/thoth/apps/resource_scope_composition.py @@ -26,6 +26,7 @@ from thoth.ports.journal import JournalPort from thoth.ports.ledger import ManagedLedgerPort from thoth.ports.operation import OperationStorePort +from thoth.ports.operation_outcome import OperationOutcomeReasonPort from thoth.ports.project import ProjectStorePort from thoth.ports.runtime import ClockPort, IdGeneratorPort from thoth.ports.sandbox import SandboxPort @@ -119,6 +120,7 @@ def scope_bus( owner: OperationOwner | None = None, journal: JournalPort | None = None, journal_completion: JournalCompletion | None = None, + outcome_reasons: OperationOutcomeReasonPort | None = None, ) -> CommandBus: handlers = OperationCommandHandlers( operations, @@ -126,6 +128,7 @@ def scope_bus( clock, resource_access=scopes, read_ledger=ledger, + outcome_reasons=outcome_reasons, ) registry.register("operation/read", handlers.read) registry.register("operation/checkpoint/read", handlers.checkpoint) diff --git a/src/thoth/apps/runtime_entry.py b/src/thoth/apps/runtime_entry.py index a7f2e07..71ff3aa 100644 --- a/src/thoth/apps/runtime_entry.py +++ b/src/thoth/apps/runtime_entry.py @@ -17,6 +17,7 @@ from thoth.application.services.connector_service import ConnectorService from thoth.application.services.full_project_memory import FullProjectMemoryService from thoth.application.services.operation_journal import OperationJournal +from thoth.application.services.research_stop_reason import ResearchStopReasons from thoth.application.services.scoped_artifacts import ScopedArtifactLedger from thoth.apps.local_retrieval_composition import create_local_retrieval from thoth.apps.project_deletion_composition import register_project_deletion @@ -130,6 +131,7 @@ def assemble_runtime_entry( owner=owner, journal_completion=journal_completion, journal=journal, + outcome_reasons=ResearchStopReasons(stores.controls), ) registration.judgment_review.connect(bus, research_component.entry) from thoth.application.commands.research_queue_cancel import QueueAwareOperationCancel diff --git a/src/thoth/domain/research_stop.py b/src/thoth/domain/research_stop.py new file mode 100644 index 0000000..447d789 --- /dev/null +++ b/src/thoth/domain/research_stop.py @@ -0,0 +1,32 @@ +"""That a person stopped a research request, kept apart from every other way one ends cancelled. + +A research request can end CANCELLED because the person pressed stop, because the time ran out or +because the server was shut down. Only the first is recorded here, so a screen can say so; the +other endings keep an unknown reason. The operation row itself is not touched: an output of an +operation needs a resource binding, which a cancelled operation does not have. +""" + +from typing import Literal + +from pydantic import JsonValue + +from thoth.domain.base import DomainModel + +RESEARCH_EXECUTION_NAMESPACE = "RESEARCH_EXECUTION" + + +class ResearchStopNote(DomainModel): + record_kind: Literal["ResearchStopNote"] = "ResearchStopNote" + schema_version: Literal["1.0.0"] = "1.0.0" + operation_id: str + reason: Literal["USER_STOPPED"] = "USER_STOPPED" + requested_at: str + + +def stop_note_key(operation_id: str) -> str: + return f"stop:{operation_id}" + + +def user_stopped_error(note: ResearchStopNote) -> dict[str, JsonValue]: + """The reason in the shape a screen already reads an operation's error in.""" + return {"message": note.reason, "data": {"reason_code": note.reason}} diff --git a/src/thoth/ports/operation_outcome.py b/src/thoth/ports/operation_outcome.py new file mode 100644 index 0000000..a096389 --- /dev/null +++ b/src/thoth/ports/operation_outcome.py @@ -0,0 +1,13 @@ +"""What an operation's reader may add about why it ended, for operations that know their reason.""" + +from typing import Protocol + +from pydantic import JsonValue + +from thoth.domain.operation import OperationRecord + + +class OperationOutcomeReasonPort(Protocol): + def error_for(self, operation: OperationRecord) -> dict[str, JsonValue] | None: + """The error to show for a finished operation with none stored, or None for no reason.""" + ... diff --git a/tests/integration/test_result_usage_read.py b/tests/integration/test_result_usage_read.py index 7717999..297525d 100644 --- a/tests/integration/test_result_usage_read.py +++ b/tests/integration/test_result_usage_read.py @@ -65,9 +65,14 @@ async def test_thread_read_reports_the_current_results_own_usage(tmp_path: Path) ) ) usage = cast(dict[str, dict[str, object]], read["result_usage"]) - assert second_op in usage and first_op not in usage - # the thread-wide total still covers both operations; the per-result entry only its own + # each round has its own entry; the thread-wide total covers both, an entry only its own + assert second_op in usage and first_op in usage thread_calls = cast(dict[str, int], read["usage"])["model_transports"] assert cast(int, usage[second_op]["calls"]) < thread_calls + assert cast(int, usage[first_op]["calls"]) < thread_calls + assert ( + cast(int, usage[first_op]["calls"]) + cast(int, usage[second_op]["calls"]) + == thread_calls + ) finally: runtime.close() diff --git a/tests/integration/test_user_stop_reason.py b/tests/integration/test_user_stop_reason.py new file mode 100644 index 0000000..e1f7097 --- /dev/null +++ b/tests/integration/test_user_stop_reason.py @@ -0,0 +1,265 @@ +"""A request the person stopped says so; one cancelled any other way keeps an unknown reason.""" + +import asyncio +from pathlib import Path +from typing import Any + +import orjson +from pydantic import JsonValue +from tests.integration.progress_wait import wait_for_event_while_progressing +from tests.integration.storage_coverage_helpers import request, value +from tests.integration.test_research_request_v2 import ControlledResearchModel, setup + + +async def _running(tmp_path: Path) -> tuple[Any, dict[str, object], str]: + model = ControlledResearchModel(wait=True) + runtime = await setup(tmp_path, model, source=False) + accepted = value( + await runtime.bus.dispatch( + request( + "thread/start", + "start", + {"project_id": "p", "problem": "Stop", "contract_version": 2}, + ) + ) + ) + await wait_for_event_while_progressing( + model.started, lambda: len(model.calls), label="the request reaches its first model call" + ) + scope: dict[str, object] = {"project_id": "p", "thread_id": accepted["thread_id"]} + return runtime, scope, str(accepted["operation_id"]) + + +async def _read(runtime: Any, scope: dict[str, object], key: str) -> dict[str, Any]: + return dict(value(await runtime.bus.dispatch(request("thread/read", key, scope)))) + + +async def _cancel_by_operation(runtime: Any, operation_id: str) -> None: + value( + await runtime.bus.dispatch( + request("operation/cancel", "cancel", {"project_id": "p", "operation_id": operation_id}) + ) + ) + await asyncio.wait_for(runtime.bus.drain(), 12) + + +async def test_a_request_the_person_stopped_is_recorded_as_stopped_by_the_person( + tmp_path: Path, +) -> None: + runtime, scope, operation_id = await _running(tmp_path) + try: + running = await _read(runtime, scope, "read-running") + assert running["operation_state"] == "RUNNING" and running["operation_error"] is None + stopped = value(await runtime.bus.dispatch(request("thread/stop", "stop", scope))) + assert stopped["cancelled"] is True + await asyncio.wait_for(runtime.bus.drain(), 12) + read = await _read(runtime, scope, "read-stopped") + assert read["operation_state"] == "CANCELLED" + assert read["operation_error"] == { + "message": "USER_STOPPED", + "data": {"reason_code": "USER_STOPPED"}, + } + # The stored operation is untouched: the reason lives beside it, not in its error. + operation = runtime.bus.read_operation(operation_id) + assert operation is not None and operation.error is None + again = await _read(runtime, scope, "read-again") + assert again["operation_error"] == read["operation_error"] + finally: + runtime.close() + + +async def test_a_request_cancelled_any_other_way_keeps_an_unknown_reason(tmp_path: Path) -> None: + runtime, scope, operation_id = await _running(tmp_path) + try: + await _cancel_by_operation(runtime, operation_id) + read = await _read(runtime, scope, "read-cancelled") + assert read["operation_state"] == "CANCELLED" and read["operation_error"] is None + finally: + runtime.close() + + +async def test_a_stop_after_the_request_was_cancelled_records_no_reason(tmp_path: Path) -> None: + runtime, scope, operation_id = await _running(tmp_path) + try: + await _cancel_by_operation(runtime, operation_id) + # The request was already cancelled: this stop ended nothing, so it takes no credit for it. + value(await runtime.bus.dispatch(request("thread/stop", "late-stop", scope))) + read = await _read(runtime, scope, "read-late") + assert read["operation_state"] == "CANCELLED" and read["operation_error"] is None + finally: + runtime.close() + + +def test_a_stop_note_is_shown_only_for_an_operation_that_ended_cancelled() -> None: + from types import SimpleNamespace + from typing import cast + + from thoth.application.services.research_read_payload import operation_error + from thoth.application.services.research_thread_read_snapshot import ResearchThreadReadSnapshot + from thoth.domain.enums import OperationState + from thoth.domain.research_stop import ResearchStopNote + + note = ResearchStopNote(operation_id="operation:x", requested_at="2026-11-12T00:00:00+00:00") + + def shown(state: OperationState, error: dict[str, Any] | None) -> object: + operation = SimpleNamespace(state=state, error=error) + snapshot = cast( + ResearchThreadReadSnapshot, SimpleNamespace(operation=operation, stop_note=note) + ) + return operation_error(snapshot) + + assert shown(OperationState.CANCELLED, None) == { + "message": "USER_STOPPED", + "data": {"reason_code": "USER_STOPPED"}, + } + assert shown(OperationState.SUCCEEDED, None) is None # it finished first: no reason is claimed + assert shown(OperationState.FAILED, None) is None + recorded = {"message": "other", "data": {"reason_code": "OTHER"}} + assert shown(OperationState.CANCELLED, recorded) == recorded # a recorded error is not replaced + + +async def _result(runtime: Any, operation_id: str, key: str) -> dict[str, Any]: + return dict( + value( + await runtime.bus.query( + request( + "operation/result/read", key, {"project_id": "p", "operation_id": operation_id} + ) + ) + ) + ) + + +async def test_the_turn_card_of_a_stopped_round_is_told_the_person_stopped_it( + tmp_path: Path, +) -> None: + """A round stopped before it saved a result has no result in the thread read; the conversation + shows its turn from operation/result/read, so the reason has to be there too.""" + runtime, scope, operation_id = await _running(tmp_path) + try: + value(await runtime.bus.dispatch(request("thread/stop", "stop", scope))) + await asyncio.wait_for(runtime.bus.drain(), 12) + listed = value(await runtime.bus.query(request("thread/activity/list", "turns", scope))) + turns = listed["conversation"]["turns"] + assert [turn["operation_id"] for turn in turns] == [operation_id] + earlier = await _result(runtime, turns[0]["operation_id"], "turn") + assert earlier["state"] == "CANCELLED" + assert earlier["error"] == { + "message": "USER_STOPPED", + "data": {"reason_code": "USER_STOPPED"}, + } + assert await _result(runtime, operation_id, "turn-again") == earlier # the same each read + # A stopped thread takes no more input: nothing is asked after a stop on this thread. + refused = await runtime.bus.dispatch( + request( + "thread/input", "again", {**scope, "instruction": "다시", "contract_version": 2} + ) + ) + assert refused.error is not None + finally: + runtime.close() + + +async def test_a_round_cancelled_without_a_stop_keeps_an_unknown_reason_in_its_turn( + tmp_path: Path, +) -> None: + """A cancel that is not a stop leaves the round unexplained, in the turn card too.""" + runtime, _scope, first_op = await _running(tmp_path) + try: + await _cancel_by_operation(runtime, first_op) + earlier = await _result(runtime, first_op, "turn") + assert earlier["state"] == "CANCELLED" and earlier["error"] is None + finally: + runtime.close() + + +async def test_operation_results_other_than_a_stopped_research_request_are_unchanged( + tmp_path: Path, +) -> None: + runtime, _scope, operation_id = await _running(tmp_path) + try: + await _cancel_by_operation(runtime, operation_id) + cancelled = dict( + value( + await runtime.bus.query( + request( + "operation/result/read", + "cancelled", + {"project_id": "p", "operation_id": operation_id}, + ) + ) + ) + ) + assert cancelled["state"] == "CANCELLED" and cancelled["error"] is None + # A reader without the research journal (as every other composition builds it) answers the + # same bytes for the same operation. + from thoth.application.commands.operations import OperationCommandHandlers + + stored = runtime.bus.read_operation(operation_id) + assert stored is not None + plain = OperationCommandHandlers(runtime.bus._operations) + injected = OperationCommandHandlers( + runtime.bus._operations, + outcome_reasons=_NoReasons(), + ) + body: dict[str, JsonValue] = {"project_id": "p", "operation_id": operation_id} + assert orjson.dumps(await plain.result(body)) == orjson.dumps(await injected.result(body)) + finally: + runtime.close() + + +class _NoReasons: + def error_for(self, operation: object) -> None: + return None + + +def test_the_operation_reader_adds_a_reason_only_to_a_cancelled_operation_with_a_stop_note() -> ( + None +): + from datetime import UTC, datetime + from types import SimpleNamespace + from typing import cast + + from thoth.application.services.research_stop_reason import ResearchStopReasons + from thoth.domain.enums import OperationState + from thoth.domain.operation import OperationRecord + from thoth.domain.research_stop import ResearchStopNote + from thoth.ports.control_record import ControlRecordStorePort + + note = ResearchStopNote(operation_id="operation:x", requested_at="2026-11-12T00:00:00+00:00") + asked: list[tuple[str, str, str]] = [] + + class Controls: + def __init__(self, record: object) -> None: + self.record = record + + def read(self, project_id: str, namespace: str, record_id: str) -> object: + asked.append((project_id, namespace, record_id)) + return self.record + + def operation(state: OperationState, error: dict[str, Any] | None) -> OperationRecord: + return OperationRecord( + operation_id="operation:x", + project_id="p", + method="thread/start", + idempotency_key="k", + scope_digest="a" * 64, + state=state, + error=error, + created_at=datetime(2026, 11, 12, tzinfo=UTC), + ) + + def reasons(record: object) -> ResearchStopReasons: + return ResearchStopReasons(cast(ControlRecordStorePort, Controls(record))) + + written = SimpleNamespace(payload=note.model_dump(mode="python")) + shown = reasons(written).error_for(operation(OperationState.CANCELLED, None)) + assert shown == {"message": "USER_STOPPED", "data": {"reason_code": "USER_STOPPED"}} + assert asked == [("p", "RESEARCH_EXECUTION", "stop:operation:x")] + asked.clear() + # Anything else gets no answer, and a finished or failed operation is not even looked up. + for state in (OperationState.SUCCEEDED, OperationState.FAILED, OperationState.RUNNING): + assert reasons(written).error_for(operation(state, None)) is None + assert reasons(written).error_for(operation(OperationState.CANCELLED, {"message": "x"})) is None + assert reasons(None).error_for(operation(OperationState.CANCELLED, None)) is None + assert len(asked) == 1 # only the cancelled operation without a stored error was looked up diff --git a/tests/unit/models/test_claude_native_placement.py b/tests/unit/models/test_claude_native_placement.py new file mode 100644 index 0000000..d0cf95d --- /dev/null +++ b/tests/unit/models/test_claude_native_placement.py @@ -0,0 +1,192 @@ +"""The Claude Code install runs no package script; THOTH places the native binary after a check.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from thoth.adapters.models.claude_code_tooling import ( + ClaudeCodeToolInstaller, + place_native_binary, +) +from thoth.adapters.models.tool_pins import ( + CLAUDE_CODE_PLATFORM_INTEGRITY, + CLAUDE_CODE_WRAPPER_INTEGRITY, +) +from thoth.ports.model_tooling import ModelToolingError + +PLACEHOLDER = b"placeholder" +NATIVE = b"native-binary-bytes" +WRAPPER = "node_modules/@anthropic-ai/claude-code" +PLATFORM = WRAPPER + "/node_modules/@anthropic-ai/claude-code-win32-x64" +OTHER_INTEGRITY = "sha512-" + "A" * 86 + "==" + + +def _tree( + prefix: Path, + *, + wrapper: str = CLAUDE_CODE_WRAPPER_INTEGRITY, + platform: str = CLAUDE_CODE_PLATFORM_INTEGRITY, + platform_file: bool = True, + lock: bool = True, +) -> Path: + """An install as npm leaves it when no script ran: the placeholder is still in bin.""" + target = prefix / WRAPPER / "bin" / "claude.exe" + target.parent.mkdir(parents=True) + target.write_bytes(PLACEHOLDER) + if platform_file: + source = prefix / PLATFORM / "claude.exe" + source.parent.mkdir(parents=True) + source.write_bytes(NATIVE) + if lock: + record = {"packages": {WRAPPER: {"integrity": wrapper}, PLATFORM: {"integrity": platform}}} + (prefix / "node_modules" / ".package-lock.json").write_text(json.dumps(record), "utf-8") + return target + + +def _untouched(target: Path) -> None: + assert target.read_bytes() == PLACEHOLDER + assert [item.name for item in target.parent.iterdir()] == ["claude.exe"] + + +def _npm_ok(argv: tuple[str, ...], _timeout: float) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, "", "") + + +def test_the_native_binary_replaces_the_placeholder_when_both_integrity_values_match( + tmp_path: Path, +) -> None: + target = _tree(tmp_path) + place_native_binary(tmp_path) + assert target.read_bytes() == NATIVE + assert [item.name for item in target.parent.iterdir()] == ["claude.exe"] + + +@pytest.mark.parametrize("which", ["wrapper", "platform"]) +def test_a_different_integrity_value_fails_the_install_and_copies_nothing( + tmp_path: Path, which: str +) -> None: + target = _tree( + tmp_path, + wrapper=OTHER_INTEGRITY if which == "wrapper" else CLAUDE_CODE_WRAPPER_INTEGRITY, + platform=OTHER_INTEGRITY if which == "platform" else CLAUDE_CODE_PLATFORM_INTEGRITY, + ) + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + place_native_binary(tmp_path) + _untouched(target) + + +def test_a_missing_platform_binary_fails_the_install_and_copies_nothing(tmp_path: Path) -> None: + target = _tree(tmp_path, platform_file=False) + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + place_native_binary(tmp_path) + _untouched(target) + + +@pytest.mark.parametrize("damage", ["missing", "not-json", "no-packages"]) +def test_a_missing_or_unreadable_install_record_fails_the_install( + tmp_path: Path, damage: str +) -> None: + target = _tree(tmp_path, lock=damage != "missing") + record = tmp_path / "node_modules" / ".package-lock.json" + if damage == "not-json": + record.write_text("{", "utf-8") + if damage == "no-packages": + record.write_text("[]", "utf-8") + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + place_native_binary(tmp_path) + _untouched(target) + + +def test_a_failed_swap_removes_the_staged_copy_and_keeps_the_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = _tree(tmp_path) + + def refuse(_source: object, _target: object) -> None: + raise PermissionError("in use") + + monkeypatch.setattr("thoth.adapters.models.claude_code_tooling.os.replace", refuse) + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + place_native_binary(tmp_path) + _untouched(target) + + +def test_the_copy_is_staged_beside_the_target_and_swapped_in_with_one_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = _tree(tmp_path) + seen: list[tuple[Path, Path, bytes]] = [] + real = os.replace + + def spy(source: Path, destination: Path) -> None: + seen.append((Path(source), Path(destination), Path(source).read_bytes())) + real(source, destination) + + monkeypatch.setattr("thoth.adapters.models.claude_code_tooling.os.replace", spy) + place_native_binary(tmp_path) + [(staged, destination, staged_bytes)] = seen + assert staged.parent == target.parent and staged != target + assert destination == target and staged_bytes == NATIVE + + +def test_an_install_that_already_has_the_native_binary_keeps_it_when_the_check_fails( + tmp_path: Path, +) -> None: + target = _tree(tmp_path, platform_file=False) + target.write_bytes(NATIVE) + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + place_native_binary(tmp_path) + assert target.read_bytes() == NATIVE + + +def test_install_runs_npm_without_scripts_and_then_places_the_binary(tmp_path: Path) -> None: + calls: list[tuple[str, ...]] = [] + order: list[str] = [] + + def npm(argv: tuple[str, ...], timeout: float) -> subprocess.CompletedProcess[str]: + calls.append(argv) + order.append("npm") + return _npm_ok(argv, timeout) + + def place(prefix: Path) -> None: + order.append("place") + assert prefix == tmp_path + + result = ClaudeCodeToolInstaller( + None, + runner=npm, + npm="npm.cmd", + prefix=tmp_path, + verify=lambda: {"connection_state": "LOGIN_REQUIRED"}, + invalidate=lambda _workspace: None, + place=place, + ).install() + assert "--ignore-scripts" in calls[0] and calls[0][-1].startswith("@anthropic-ai/claude-code@") + assert order == ["npm", "place"] and result["installed"] is True + + +def test_install_does_not_report_success_when_the_integrity_check_fails(tmp_path: Path) -> None: + target = _tree(tmp_path, platform=OTHER_INTEGRITY) + checked: list[bool] = [] + + def verify() -> dict[str, object]: + checked.append(True) + return {"connection_state": "LOGIN_REQUIRED"} + + installer = ClaudeCodeToolInstaller( + None, + runner=_npm_ok, + npm="npm.cmd", + prefix=tmp_path, + verify=verify, + invalidate=lambda _workspace: None, + ) + with pytest.raises(ModelToolingError, match="CLAUDE_CODE_TOOL_INSTALL_FAILED"): + installer.install() + assert checked == [] + _untouched(target) diff --git a/tests/unit/models/test_tool_installers.py b/tests/unit/models/test_tool_installers.py index 020b52a..884387e 100644 --- a/tests/unit/models/test_tool_installers.py +++ b/tests/unit/models/test_tool_installers.py @@ -54,6 +54,7 @@ def verify() -> CodexExecutableIdentity: str(prefix), "--install-strategy=nested", "--save-exact", + "--ignore-scripts", "@openai/codex@0.157.1", ), 300.0, @@ -114,6 +115,7 @@ def test_claude_code_install_targets_its_own_folder_and_clears_the_cached_status env={"LOCALAPPDATA": str(local)}, verify=lambda: {"connection_state": "LOGIN_REQUIRED"}, invalidate=lambda workspace: cleared.append(workspace), + place=lambda _prefix: None, ) result = installer.install() argv, timeout = npm.calls[0] @@ -137,6 +139,7 @@ def test_claude_code_install_reports_an_unusable_result_instead_of_success( "reason_code": "CLAUDE_CODE_CAPABILITY_UNSUPPORTED", }, invalidate=lambda _workspace: None, + place=lambda _prefix: None, ) with pytest.raises(ModelToolingError, match="CLAUDE_CODE_CAPABILITY_UNSUPPORTED"): installer.install()