From ae56cb3e3c0bbbc9de7a7268a5afd86771e6abe3 Mon Sep 17 00:00:00 2001 From: THOTH Date: Fri, 13 Nov 2026 03:08:12 +0900 Subject: [PATCH] Run tool installs without package scripts; show why research stopped and earlier usage The Codex and Claude Code installs now pass --ignore-scripts. Claude Code's own install script only copies the platform binary over a placeholder, so THOTH does that copy itself, and only when the integrity npm recorded for the wrapper and platform packages matches the values pinned in tool_pins.py. A research request the person stopped now records that fact, and the result card says "You stopped it yourself" instead of "reason not available", on the latest round and on earlier rounds. Cancellations for other reasons and older records are unchanged. Earlier rounds now show the token usage recorded for them. --- SOURCE_MANIFEST.json | 114 +++++--- .../ConversationEarlierRound.test.tsx | 64 ++++- .../src/components/ConversationTimeline.tsx | 10 +- .../ResultInterruptionNotice.test.tsx | 47 ++++ .../components/ResultInterruptionNotice.tsx | 10 +- apps/web/src/i18n/ja/researchMessagesJa.ts | 1 + apps/web/src/i18n/researchMessages.ts | 4 + apps/web/src/i18n/zh/researchMessagesZh.ts | 1 + .../src/i18n/zhHant/researchMessagesZhHant.ts | 1 + docs/INSTALL.md | 13 +- .../adapters/models/claude_code_tooling.py | 71 ++++- src/thoth/adapters/models/codex_profile.py | 20 +- src/thoth/adapters/models/tool_installers.py | 7 +- src/thoth/adapters/models/tool_pins.py | 27 ++ src/thoth/application/commands/operations.py | 11 +- .../application/services/research_controls.py | 17 +- .../services/research_read_payload.py | 18 +- .../services/research_stop_reason.py | 35 +++ .../services/research_thread_read_snapshot.py | 6 + .../services/research_usage_read.py | 12 +- src/thoth/apps/resource_scope_composition.py | 3 + src/thoth/apps/runtime_entry.py | 2 + src/thoth/domain/research_stop.py | 32 +++ src/thoth/ports/operation_outcome.py | 13 + tests/integration/test_result_usage_read.py | 9 +- tests/integration/test_user_stop_reason.py | 265 ++++++++++++++++++ .../models/test_claude_native_placement.py | 192 +++++++++++++ tests/unit/models/test_tool_installers.py | 3 + 28 files changed, 924 insertions(+), 84 deletions(-) create mode 100644 src/thoth/adapters/models/tool_pins.py create mode 100644 src/thoth/application/services/research_stop_reason.py create mode 100644 src/thoth/domain/research_stop.py create mode 100644 src/thoth/ports/operation_outcome.py create mode 100644 tests/integration/test_user_stop_reason.py create mode 100644 tests/unit/models/test_claude_native_placement.py diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index e85290b8..fb3bbdda 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 6af70752..2cf28187 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 5402c38c..5ebfbb97 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 b44f45af..202724ae 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 03c7db5f..7b4999cd 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 71fe3c08..816f8410 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 5598cf41..fdc31743 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 8372b8b5..6db8ce8c 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 dbcab22c..455b7909 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 bdbce020..9c9afd07 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 fa80704f..10b213fc 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 d922be1c..6c5d0ce8 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 00000000..e78204ef --- /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 344c235c..71a30baa 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 54bbc6a1..b02a6843 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 608d6add..6156efec 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 00000000..21f28d01 --- /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 14ce291a..fd23865f 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 121715b5..e5391d30 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 7754c739..9ab004f9 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 a7f2e07d..71ff3aa6 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 00000000..447d789c --- /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 00000000..a096389a --- /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 7717999e..297525d0 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 00000000..e1f7097e --- /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 00000000..d0cf95de --- /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 020b52ad..884387e6 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()