t3code-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +90 -35
  2. package/dist/application.js +1 -1
  3. package/dist/auth.js +1 -2
  4. package/dist/bin.js +241 -45
  5. package/dist/cli.js +1 -1
  6. package/dist/config.js +1 -3
  7. package/dist/connection.js +1 -4
  8. package/dist/index.js +1 -4
  9. package/dist/layout.js +1 -1
  10. package/dist/orchestration.js +1 -1
  11. package/dist/rpc.js +1 -1
  12. package/dist/runtime.js +1 -2
  13. package/dist/scope.js +1 -1
  14. package/dist/shared.js +85492 -0
  15. package/dist/src/application/layer.d.ts +86 -2
  16. package/dist/src/application/models.d.ts +1 -0
  17. package/dist/src/application/projects.d.ts +10 -1
  18. package/dist/src/application/service.d.ts +30 -1
  19. package/dist/src/application/shell-sequence.d.ts +9 -0
  20. package/dist/src/application/thread-commands.d.ts +27 -1
  21. package/dist/src/application/thread-wait.d.ts +9 -0
  22. package/dist/src/application/threads.d.ts +102 -2
  23. package/dist/src/cli-path/layer.d.ts +3 -0
  24. package/dist/src/cli-path/service.d.ts +8 -0
  25. package/dist/src/domain/helpers.d.ts +9 -0
  26. package/dist/src/domain/model-config.d.ts +3 -0
  27. package/dist/src/domain/thread-activities.d.ts +14 -0
  28. package/dist/src/domain/thread-lifecycle.d.ts +10 -0
  29. package/dist/src/orchestration/layer.d.ts +215 -0
  30. package/dist/src/rpc/layer.d.ts +466 -0
  31. package/dist/src/rpc/ws-group.d.ts +142 -0
  32. package/dist/src/runtime/layer.d.ts +1 -1
  33. package/dist/t3tools.js +2 -2
  34. package/dist/upstream-t3code/packages/contracts/src/environmentHttp.d.ts +33 -10
  35. package/dist/upstream-t3code/packages/contracts/src/ipc.d.ts +6 -0
  36. package/dist/upstream-t3code/packages/contracts/src/orchestration.d.ts +657 -1
  37. package/dist/upstream-t3code/packages/contracts/src/provider.d.ts +14 -0
  38. package/dist/upstream-t3code/packages/contracts/src/providerRuntime.d.ts +175 -1
  39. package/dist/upstream-t3code/packages/contracts/src/rpc.d.ts +492 -0
  40. package/dist/upstream-t3code/packages/contracts/src/server.d.ts +45 -0
  41. package/dist/upstream-t3code/packages/contracts/src/settings.d.ts +21 -0
  42. package/dist/upstream-t3code/packages/contracts/src/terminal.d.ts +6 -3
  43. package/package.json +2 -2
  44. package/src/application/service.ts +32 -0
  45. package/src/application/thread-commands.ts +45 -0
  46. package/src/application/threads.ts +100 -1
  47. package/src/cli/error.ts +15 -0
  48. package/src/cli/message-input.ts +30 -0
  49. package/src/cli/thread-format.ts +47 -0
  50. package/src/cli/thread.ts +8 -0
  51. package/src/cli/threads/approve.ts +61 -0
  52. package/src/cli/threads/archive.ts +18 -3
  53. package/src/cli/threads/callback.ts +92 -0
  54. package/src/cli/threads/respond.ts +70 -0
  55. package/src/cli/threads/show.ts +43 -0
  56. package/src/cli-path/layer.ts +10 -0
  57. package/src/cli-path/service.ts +7 -0
  58. package/src/domain/thread-activities.test.ts +321 -0
  59. package/src/domain/thread-activities.ts +244 -0
  60. package/src/domain/thread-lifecycle.ts +2 -0
  61. package/src/runtime/layer.ts +2 -0
  62. package/dist/Context-DueQ9iMH.js +0 -4916
  63. package/dist/Path-D8WPdPwR.js +0 -11406
  64. package/dist/Schema-DsQxYh6_.js +0 -13581
  65. package/dist/UrlParams-BA6gBvaY.js +0 -205
  66. package/dist/base-dir-R12OMDso.js +0 -20
  67. package/dist/error-B2t1bAP9.js +0 -169
  68. package/dist/error-BHRnjLux.js +0 -15
  69. package/dist/error-jwMt3VoW.js +0 -54
  70. package/dist/flags-CM7_iGdA.js +0 -13371
  71. package/dist/layer-CfC5qZol.js +0 -1508
  72. package/dist/layer-DHhKS5jd.js +0 -13
  73. package/dist/layer-DUv99vsS.js +0 -72
  74. package/dist/layer-DvHnKBYj.js +0 -29466
  75. package/dist/layer-MqCKkIGD.js +0 -1206
  76. package/dist/scope-GycYiJ54.js +0 -29
  77. package/dist/service-CLmRO2Dp.js +0 -8
  78. package/dist/service-ybOWV9pL.js +0 -5
  79. package/dist/src-KdbHqrex.js +0 -8874
  80. package/dist/transport-D3zBdZ1h.js +0 -539
  81. package/dist/url-SlsaG8nY.js +0 -165
  82. /package/dist/{chunk-B5meny8j.js → rolldown-runtime.js} +0 -0
package/src/cli/thread.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { Command } from "effect/unstable/cli";
2
2
 
3
+ import { approveThreadCommand } from "./threads/approve.ts";
3
4
  import { archiveThreadCommand } from "./threads/archive.ts";
5
+ import { callbackThreadCommand } from "./threads/callback.ts";
4
6
  import { listThreadsCommand } from "./threads/list.ts";
5
7
  import { getThreadMessagesCommand } from "./threads/messages.ts";
8
+ import { respondThreadCommand } from "./threads/respond.ts";
6
9
  import { sendThreadCommand } from "./threads/send.ts";
10
+ import { showThreadCommand } from "./threads/show.ts";
7
11
  import { startThreadCommand } from "./threads/start.ts";
8
12
  import { waitForThreadCommand } from "./threads/wait.ts";
9
13
 
@@ -14,9 +18,13 @@ export function createThreadCommand() {
14
18
  listThreadsCommand,
15
19
  startThreadCommand,
16
20
  sendThreadCommand,
21
+ showThreadCommand,
22
+ approveThreadCommand,
23
+ respondThreadCommand,
17
24
  archiveThreadCommand,
18
25
  getThreadMessagesCommand,
19
26
  waitForThreadCommand,
27
+ callbackThreadCommand,
20
28
  ]),
21
29
  );
22
30
  }
@@ -0,0 +1,61 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import { Command, Flag } from "effect/unstable/cli";
4
+
5
+ import { formatFlag, threadFlag } from "../flags.ts";
6
+ import { MissingRequestError, MissingThreadError } from "../error.ts";
7
+ import { resolveThreadId } from "../../scope/index.ts";
8
+ import { T3Application } from "../../application/service.ts";
9
+ import { Environment } from "../../environment/service.ts";
10
+ import { resolveOutputFormat } from "../output-format.ts";
11
+ import { T3Output } from "../output/service.ts";
12
+
13
+ const approvalDecisionFlag = Flag.choice("decision", ["accept", "decline", "cancel"] as const).pipe(
14
+ Flag.withDescription("Approval decision"),
15
+ );
16
+
17
+ const requestFlag = Flag.string("request").pipe(
18
+ Flag.withDescription("Pending approval request id"),
19
+ Flag.optional,
20
+ );
21
+
22
+ export const approveThreadCommand = Command.make(
23
+ "approve",
24
+ {
25
+ thread: threadFlag,
26
+ request: requestFlag,
27
+ decision: approvalDecisionFlag,
28
+ format: formatFlag,
29
+ },
30
+ ({ thread, request, decision, format }) =>
31
+ Effect.gen(function* () {
32
+ const application = yield* T3Application;
33
+ const environment = yield* Environment;
34
+ const output = yield* T3Output;
35
+ const threadId = resolveThreadId({
36
+ value: Option.getOrUndefined(thread),
37
+ env: environment.env,
38
+ });
39
+ if (threadId === undefined) {
40
+ return yield* Effect.fail(
41
+ new MissingThreadError({
42
+ message: "thread id is required: pass --thread or set T3CODE_THREAD_ID",
43
+ }),
44
+ );
45
+ }
46
+ const requestId = Option.getOrUndefined(request);
47
+ if (requestId === undefined || requestId.length === 0) {
48
+ return yield* Effect.fail(
49
+ new MissingRequestError({ message: "request id is required: pass --request" }),
50
+ );
51
+ }
52
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
53
+ const result = yield* application.approveThread({ threadId, requestId, decision });
54
+ if (resolvedFormat === "json") {
55
+ return yield* output.printJson(result);
56
+ }
57
+ return yield* output.printInfo(
58
+ `approval submitted: ${result.requestId}\nsequence: ${result.dispatch.sequence}`,
59
+ );
60
+ }),
61
+ ).pipe(Command.withDescription("respond to a pending approval request"));
@@ -1,9 +1,9 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Option from "effect/Option";
3
- import { Command } from "effect/unstable/cli";
3
+ import { Command, Flag } from "effect/unstable/cli";
4
4
 
5
5
  import { formatFlag, threadFlag } from "../flags.ts";
6
- import { MissingThreadError } from "../error.ts";
6
+ import { MissingThreadError, SelfArchiveError } from "../error.ts";
7
7
  import { resolveThreadId } from "../../scope/index.ts";
8
8
  import { T3Application } from "../../application/service.ts";
9
9
  import { Environment } from "../../environment/service.ts";
@@ -14,9 +14,10 @@ export const archiveThreadCommand = Command.make(
14
14
  "archive",
15
15
  {
16
16
  thread: threadFlag,
17
+ force: Flag.boolean("force").pipe(Flag.withAlias("f")),
17
18
  format: formatFlag,
18
19
  },
19
- ({ thread, format }) =>
20
+ ({ thread, force, format }) =>
20
21
  Effect.gen(function* () {
21
22
  const application = yield* T3Application;
22
23
  const environment = yield* Environment;
@@ -32,6 +33,20 @@ export const archiveThreadCommand = Command.make(
32
33
  }),
33
34
  );
34
35
  }
36
+ const envThreadId = environment.env.T3CODE_THREAD_ID;
37
+ if (
38
+ envThreadId !== undefined &&
39
+ envThreadId.length > 0 &&
40
+ threadId === envThreadId &&
41
+ !force
42
+ ) {
43
+ return yield* Effect.fail(
44
+ new SelfArchiveError({
45
+ threadId,
46
+ message: `cannot archive thread ${threadId}: matches T3CODE_THREAD_ID (are you trying to archive yourself?). Use --force to override.`,
47
+ }),
48
+ );
49
+ }
35
50
  const resolvedFormat = resolveOutputFormat(format, environment, "json");
36
51
  const dispatch = yield* application.archiveThread(threadId);
37
52
  if (resolvedFormat === "json") {
@@ -0,0 +1,92 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as ChildProcess from "effect/unstable/process/ChildProcess";
4
+ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
5
+ import { Command, Flag } from "effect/unstable/cli";
6
+
7
+ import { threadFlag } from "../flags.ts";
8
+ import { MissingThreadError } from "../error.ts";
9
+ import { resolveThreadId } from "../../scope/index.ts";
10
+ import { Environment } from "../../environment/service.ts";
11
+ import { T3Application } from "../../application/service.ts";
12
+ import { T3Output } from "../output/service.ts";
13
+ import { CliPath } from "../../cli-path/service.ts";
14
+
15
+ export const callbackThreadCommand = Command.make(
16
+ "callback",
17
+ {
18
+ from: Flag.string("from").pipe(Flag.withDescription("Thread ID to watch for completion")),
19
+ thread: threadFlag,
20
+ prompt: Flag.string("prompt").pipe(Flag.withDescription("Message to send to target thread")),
21
+ background: Flag.boolean("background").pipe(
22
+ Flag.withDescription("Fork and detach as background process"),
23
+ Flag.optional,
24
+ ),
25
+ },
26
+ ({ from, thread, prompt, background }) =>
27
+ Effect.gen(function* () {
28
+ const application = yield* T3Application;
29
+ const environment = yield* Environment;
30
+ const output = yield* T3Output;
31
+ const cliPath = yield* CliPath;
32
+ const spawner = yield* ChildProcessSpawner;
33
+
34
+ const fromThreadId = from;
35
+
36
+ const targetThreadId = resolveThreadId({
37
+ value: Option.getOrUndefined(thread),
38
+ env: environment.env,
39
+ });
40
+ if (targetThreadId === undefined) {
41
+ return yield* Effect.fail(
42
+ new MissingThreadError({
43
+ message: "target thread id is required: pass --thread or set T3CODE_THREAD_ID",
44
+ }),
45
+ );
46
+ }
47
+
48
+ const isBackground = Option.getOrElse(background, () => false);
49
+ if (isBackground) {
50
+ const args = [
51
+ cliPath.path,
52
+ "thread",
53
+ "callback",
54
+ "--from",
55
+ fromThreadId,
56
+ "--thread",
57
+ targetThreadId,
58
+ "--prompt",
59
+ prompt,
60
+ ];
61
+
62
+ const proc = ChildProcess.make(process.execPath, args, {
63
+ detached: true,
64
+ stdin: "ignore",
65
+ stdout: "ignore",
66
+ stderr: "ignore",
67
+ });
68
+
69
+ const handle = yield* spawner.spawn(proc);
70
+ const reref = yield* handle.unref;
71
+ yield* reref.pipe(Effect.ignore);
72
+
73
+ yield* output.printInfo(
74
+ `background callback scheduled: ${fromThreadId} -> ${targetThreadId} (pid: ${handle.pid})`,
75
+ );
76
+ return undefined;
77
+ }
78
+
79
+ yield* output.printInfo(`watching thread ${fromThreadId} for completion...`);
80
+
81
+ const result = yield* application.callbackThread({
82
+ fromThreadId,
83
+ targetThreadId,
84
+ prompt,
85
+ });
86
+
87
+ yield* output.printInfo(
88
+ `callback sent to ${result.targetThreadId} (dispatch: ${result.dispatch.sequence})`,
89
+ );
90
+ return undefined;
91
+ }),
92
+ ).pipe(Command.withDescription("watch a thread and send a message when it completes"));
@@ -0,0 +1,70 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import { Command, Flag } from "effect/unstable/cli";
4
+
5
+ import { formatFlag, threadFlag } from "../flags.ts";
6
+ import { MissingRequestError, MissingThreadError } from "../error.ts";
7
+ import { readJsonAnswers } from "../message-input.ts";
8
+ import { resolveThreadId } from "../../scope/index.ts";
9
+ import { T3Application } from "../../application/service.ts";
10
+ import { Environment } from "../../environment/service.ts";
11
+ import { T3Input } from "../input/service.ts";
12
+ import { resolveOutputFormat } from "../output-format.ts";
13
+ import { T3Output } from "../output/service.ts";
14
+
15
+ const requestFlag = Flag.string("request").pipe(
16
+ Flag.withDescription("Pending user-input request id"),
17
+ Flag.optional,
18
+ );
19
+
20
+ export const respondThreadCommand = Command.make(
21
+ "respond",
22
+ {
23
+ thread: threadFlag,
24
+ request: requestFlag,
25
+ answers: Flag.string("answers").pipe(Flag.optional),
26
+ stdin: Flag.boolean("stdin"),
27
+ format: formatFlag,
28
+ },
29
+ ({ thread, request, answers, stdin, format }) =>
30
+ Effect.gen(function* () {
31
+ const inputService = yield* T3Input;
32
+ const parsedAnswers = yield* readJsonAnswers({
33
+ answers: Option.getOrUndefined(answers),
34
+ fromStdin: stdin,
35
+ readStdin: inputService.readStdin,
36
+ });
37
+ const application = yield* T3Application;
38
+ const environment = yield* Environment;
39
+ const output = yield* T3Output;
40
+ const threadId = resolveThreadId({
41
+ value: Option.getOrUndefined(thread),
42
+ env: environment.env,
43
+ });
44
+ if (threadId === undefined) {
45
+ return yield* Effect.fail(
46
+ new MissingThreadError({
47
+ message: "thread id is required: pass --thread or set T3CODE_THREAD_ID",
48
+ }),
49
+ );
50
+ }
51
+ const requestId = Option.getOrUndefined(request);
52
+ if (requestId === undefined || requestId.length === 0) {
53
+ return yield* Effect.fail(
54
+ new MissingRequestError({ message: "request id is required: pass --request" }),
55
+ );
56
+ }
57
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
58
+ const result = yield* application.respondToThread({
59
+ threadId,
60
+ requestId,
61
+ answers: parsedAnswers,
62
+ });
63
+ if (resolvedFormat === "json") {
64
+ return yield* output.printJson(result);
65
+ }
66
+ return yield* output.printInfo(
67
+ `user input submitted: ${result.requestId}\nsequence: ${result.dispatch.sequence}`,
68
+ );
69
+ }),
70
+ ).pipe(Command.withDescription("respond to a pending user-input request"));
@@ -0,0 +1,43 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import { Command } from "effect/unstable/cli";
4
+
5
+ import { formatFlag, threadFlag } from "../flags.ts";
6
+ import { MissingThreadError } from "../error.ts";
7
+ import { resolveThreadId } from "../../scope/index.ts";
8
+ import { formatThreadShowHuman, formatThreadShowJson } from "../thread-format.ts";
9
+ import { T3Application } from "../../application/service.ts";
10
+ import { Environment } from "../../environment/service.ts";
11
+ import { resolveOutputFormat } from "../output-format.ts";
12
+ import { T3Output } from "../output/service.ts";
13
+
14
+ export const showThreadCommand = Command.make(
15
+ "show",
16
+ {
17
+ thread: threadFlag,
18
+ format: formatFlag,
19
+ },
20
+ ({ thread, format }) =>
21
+ Effect.gen(function* () {
22
+ const application = yield* T3Application;
23
+ const environment = yield* Environment;
24
+ const output = yield* T3Output;
25
+ const threadId = resolveThreadId({
26
+ value: Option.getOrUndefined(thread),
27
+ env: environment.env,
28
+ });
29
+ if (threadId === undefined) {
30
+ return yield* Effect.fail(
31
+ new MissingThreadError({
32
+ message: "thread id is required: pass --thread or set T3CODE_THREAD_ID",
33
+ }),
34
+ );
35
+ }
36
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
37
+ const detail = yield* application.showThread(threadId);
38
+ if (resolvedFormat === "json") {
39
+ return yield* output.printJson(formatThreadShowJson(detail));
40
+ }
41
+ return yield* output.writeStdout(formatThreadShowHuman(detail));
42
+ }),
43
+ ).pipe(Command.withDescription("show thread status and pending requests"));
@@ -0,0 +1,10 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Layer from "effect/Layer";
3
+
4
+ import { CliPath } from "./service.ts";
5
+
6
+ const makeCliPath = Effect.sync(() => ({
7
+ path: process.argv[1] ?? "dist/bin.js",
8
+ }));
9
+
10
+ export const NodeCliPathLayer = Layer.effect(CliPath, makeCliPath);
@@ -0,0 +1,7 @@
1
+ import * as Context from "effect/Context";
2
+
3
+ export type CliPathShape = {
4
+ readonly path: string;
5
+ };
6
+
7
+ export class CliPath extends Context.Service<CliPath, CliPathShape>()("t3cli/CliPath") {}
@@ -0,0 +1,321 @@
1
+ import "vite-plus/test/config";
2
+
3
+ import { EventId, TurnId, type OrchestrationThreadActivity } from "#t3tools/contracts";
4
+ import { describe, expect, it } from "vite-plus/test";
5
+
6
+ import { derivePendingApprovals, derivePendingUserInputs } from "./thread-activities.ts";
7
+
8
+ let nextActivityId = 0;
9
+
10
+ function makeActivity(overrides: {
11
+ id?: string;
12
+ createdAt?: string;
13
+ kind?: string;
14
+ summary?: string;
15
+ tone?: OrchestrationThreadActivity["tone"];
16
+ payload?: Record<string, unknown>;
17
+ turnId?: string;
18
+ sequence?: number;
19
+ }): OrchestrationThreadActivity {
20
+ const payload = overrides.payload ?? {};
21
+ return {
22
+ id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),
23
+ createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",
24
+ kind: overrides.kind ?? "tool.started",
25
+ summary: overrides.summary ?? "Tool call",
26
+ tone: overrides.tone ?? "tool",
27
+ payload,
28
+ turnId: overrides.turnId !== undefined ? TurnId.make(overrides.turnId) : null,
29
+ ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}),
30
+ };
31
+ }
32
+
33
+ describe("derivePendingApprovals", () => {
34
+ it("tracks open approvals and removes resolved ones", () => {
35
+ const activities: OrchestrationThreadActivity[] = [
36
+ makeActivity({
37
+ id: "approval-open",
38
+ createdAt: "2026-02-23T00:00:01.000Z",
39
+ kind: "approval.requested",
40
+ summary: "Command approval requested",
41
+ tone: "approval",
42
+ payload: {
43
+ requestId: "req-1",
44
+ requestKind: "command",
45
+ detail: "bun run lint",
46
+ },
47
+ }),
48
+ makeActivity({
49
+ id: "approval-close",
50
+ createdAt: "2026-02-23T00:00:02.000Z",
51
+ kind: "approval.resolved",
52
+ summary: "Approval resolved",
53
+ tone: "info",
54
+ payload: { requestId: "req-2" },
55
+ }),
56
+ makeActivity({
57
+ id: "approval-closed-request",
58
+ createdAt: "2026-02-23T00:00:01.500Z",
59
+ kind: "approval.requested",
60
+ summary: "File-change approval requested",
61
+ tone: "approval",
62
+ payload: { requestId: "req-2", requestKind: "file-change" },
63
+ }),
64
+ ];
65
+
66
+ expect(derivePendingApprovals(activities)).toEqual([
67
+ {
68
+ requestId: "req-1",
69
+ requestKind: "command",
70
+ createdAt: "2026-02-23T00:00:01.000Z",
71
+ detail: "bun run lint",
72
+ },
73
+ ]);
74
+ });
75
+
76
+ it("maps canonical requestType payloads into pending approvals", () => {
77
+ const activities: OrchestrationThreadActivity[] = [
78
+ makeActivity({
79
+ id: "approval-open-request-type",
80
+ createdAt: "2026-02-23T00:00:01.000Z",
81
+ kind: "approval.requested",
82
+ summary: "Command approval requested",
83
+ tone: "approval",
84
+ payload: {
85
+ requestId: "req-request-type",
86
+ requestType: "command_execution_approval",
87
+ detail: "pwd",
88
+ },
89
+ }),
90
+ ];
91
+
92
+ expect(derivePendingApprovals(activities)).toEqual([
93
+ {
94
+ requestId: "req-request-type",
95
+ requestKind: "command",
96
+ createdAt: "2026-02-23T00:00:01.000Z",
97
+ detail: "pwd",
98
+ },
99
+ ]);
100
+ });
101
+
102
+ it("clears stale pending approvals when provider reports unknown pending request", () => {
103
+ const activities: OrchestrationThreadActivity[] = [
104
+ makeActivity({
105
+ id: "approval-open-stale",
106
+ createdAt: "2026-02-23T00:00:01.000Z",
107
+ kind: "approval.requested",
108
+ summary: "Command approval requested",
109
+ tone: "approval",
110
+ payload: {
111
+ requestId: "req-stale-1",
112
+ requestKind: "command",
113
+ },
114
+ }),
115
+ makeActivity({
116
+ id: "approval-failed-stale",
117
+ createdAt: "2026-02-23T00:00:02.000Z",
118
+ kind: "provider.approval.respond.failed",
119
+ summary: "Provider approval response failed",
120
+ tone: "error",
121
+ payload: {
122
+ requestId: "req-stale-1",
123
+ detail: "Unknown pending permission request: req-stale-1",
124
+ },
125
+ }),
126
+ ];
127
+
128
+ expect(derivePendingApprovals(activities)).toEqual([]);
129
+ });
130
+
131
+ it("clears stale pending approvals when request and stale failure share createdAt", () => {
132
+ const activities: OrchestrationThreadActivity[] = [
133
+ makeActivity({
134
+ id: "z-approval-open-same-ts",
135
+ createdAt: "2026-02-23T00:00:01.000Z",
136
+ kind: "approval.requested",
137
+ summary: "Command approval requested",
138
+ tone: "approval",
139
+ payload: {
140
+ requestId: "req-same-ts-1",
141
+ requestKind: "command",
142
+ },
143
+ }),
144
+ makeActivity({
145
+ id: "a-approval-failed-same-ts",
146
+ createdAt: "2026-02-23T00:00:01.000Z",
147
+ kind: "provider.approval.respond.failed",
148
+ summary: "Provider approval response failed",
149
+ tone: "error",
150
+ payload: {
151
+ requestId: "req-same-ts-1",
152
+ detail: "Unknown pending permission request: req-same-ts-1",
153
+ },
154
+ }),
155
+ ];
156
+
157
+ expect(derivePendingApprovals(activities)).toEqual([]);
158
+ });
159
+
160
+ it("clears stale pending approvals when the backend marks them stale after restart", () => {
161
+ const activities: OrchestrationThreadActivity[] = [
162
+ makeActivity({
163
+ id: "approval-open-stale-restart",
164
+ createdAt: "2026-02-23T00:00:01.000Z",
165
+ kind: "approval.requested",
166
+ summary: "Command approval requested",
167
+ tone: "approval",
168
+ payload: {
169
+ requestId: "req-stale-restart-1",
170
+ requestKind: "command",
171
+ },
172
+ }),
173
+ makeActivity({
174
+ id: "approval-failed-stale-restart",
175
+ createdAt: "2026-02-23T00:00:02.000Z",
176
+ kind: "provider.approval.respond.failed",
177
+ summary: "Provider approval response failed",
178
+ tone: "error",
179
+ payload: {
180
+ requestId: "req-stale-restart-1",
181
+ detail:
182
+ "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
183
+ },
184
+ }),
185
+ ];
186
+
187
+ expect(derivePendingApprovals(activities)).toEqual([]);
188
+ });
189
+ });
190
+
191
+ describe("derivePendingUserInputs", () => {
192
+ it("tracks open structured prompts and removes resolved ones", () => {
193
+ const activities: OrchestrationThreadActivity[] = [
194
+ makeActivity({
195
+ id: "user-input-open",
196
+ createdAt: "2026-02-23T00:00:01.000Z",
197
+ kind: "user-input.requested",
198
+ summary: "User input requested",
199
+ tone: "info",
200
+ payload: {
201
+ requestId: "req-user-input-1",
202
+ questions: [
203
+ {
204
+ id: "sandbox_mode",
205
+ header: "Sandbox",
206
+ question: "Which mode should be used?",
207
+ options: [
208
+ {
209
+ label: "workspace-write",
210
+ description: "Allow workspace writes only",
211
+ },
212
+ ],
213
+ multiSelect: true,
214
+ },
215
+ ],
216
+ },
217
+ }),
218
+ makeActivity({
219
+ id: "user-input-resolved",
220
+ createdAt: "2026-02-23T00:00:02.000Z",
221
+ kind: "user-input.resolved",
222
+ summary: "User input submitted",
223
+ tone: "info",
224
+ payload: {
225
+ requestId: "req-user-input-2",
226
+ answers: {
227
+ sandbox_mode: "workspace-write",
228
+ },
229
+ },
230
+ }),
231
+ makeActivity({
232
+ id: "user-input-open-2",
233
+ createdAt: "2026-02-23T00:00:01.500Z",
234
+ kind: "user-input.requested",
235
+ summary: "User input requested",
236
+ tone: "info",
237
+ payload: {
238
+ requestId: "req-user-input-2",
239
+ questions: [
240
+ {
241
+ id: "approval",
242
+ header: "Approval",
243
+ question: "Continue?",
244
+ options: [
245
+ {
246
+ label: "yes",
247
+ description: "Continue execution",
248
+ },
249
+ ],
250
+ multiSelect: false,
251
+ },
252
+ ],
253
+ },
254
+ }),
255
+ ];
256
+
257
+ expect(derivePendingUserInputs(activities)).toEqual([
258
+ {
259
+ requestId: "req-user-input-1",
260
+ createdAt: "2026-02-23T00:00:01.000Z",
261
+ questions: [
262
+ {
263
+ id: "sandbox_mode",
264
+ header: "Sandbox",
265
+ question: "Which mode should be used?",
266
+ options: [
267
+ {
268
+ label: "workspace-write",
269
+ description: "Allow workspace writes only",
270
+ },
271
+ ],
272
+ multiSelect: true,
273
+ },
274
+ ],
275
+ },
276
+ ]);
277
+ });
278
+
279
+ it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
280
+ const activities: OrchestrationThreadActivity[] = [
281
+ makeActivity({
282
+ id: "user-input-open-stale",
283
+ createdAt: "2026-02-23T00:00:01.000Z",
284
+ kind: "user-input.requested",
285
+ summary: "User input requested",
286
+ tone: "info",
287
+ payload: {
288
+ requestId: "req-user-input-stale-1",
289
+ questions: [
290
+ {
291
+ id: "sandbox_mode",
292
+ header: "Sandbox",
293
+ question: "Which mode should be used?",
294
+ options: [
295
+ {
296
+ label: "workspace-write",
297
+ description: "Allow workspace writes only",
298
+ },
299
+ ],
300
+ multiSelect: false,
301
+ },
302
+ ],
303
+ },
304
+ }),
305
+ makeActivity({
306
+ id: "user-input-failed-stale",
307
+ createdAt: "2026-02-23T00:00:02.000Z",
308
+ kind: "provider.user-input.respond.failed",
309
+ summary: "Provider user input response failed",
310
+ tone: "error",
311
+ payload: {
312
+ requestId: "req-user-input-stale-1",
313
+ detail:
314
+ "Stale pending user-input request: req-user-input-stale-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
315
+ },
316
+ }),
317
+ ];
318
+
319
+ expect(derivePendingUserInputs(activities)).toEqual([]);
320
+ });
321
+ });