t3code-cli 0.8.0 → 0.9.1

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 (60) 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 +190 -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 +25 -2
  16. package/dist/src/application/projects.d.ts +1 -1
  17. package/dist/src/application/service.d.ts +21 -1
  18. package/dist/src/application/thread-commands.d.ts +27 -1
  19. package/dist/src/application/threads.d.ts +50 -1
  20. package/dist/src/domain/thread-activities.d.ts +14 -0
  21. package/dist/src/domain/thread-lifecycle.d.ts +1 -0
  22. package/dist/src/runtime/layer.d.ts +1 -1
  23. package/dist/t3tools.js +1 -1
  24. package/package.json +1 -1
  25. package/src/application/service.ts +19 -0
  26. package/src/application/thread-commands.ts +45 -0
  27. package/src/application/threads.ts +85 -0
  28. package/src/cli/error.ts +15 -0
  29. package/src/cli/message-input.ts +30 -0
  30. package/src/cli/thread-format.ts +47 -0
  31. package/src/cli/thread.ts +6 -0
  32. package/src/cli/threads/approve.ts +61 -0
  33. package/src/cli/threads/archive.ts +18 -3
  34. package/src/cli/threads/callback.ts +1 -2
  35. package/src/cli/threads/respond.ts +70 -0
  36. package/src/cli/threads/show.ts +43 -0
  37. package/src/domain/thread-activities.test.ts +321 -0
  38. package/src/domain/thread-activities.ts +244 -0
  39. package/src/domain/thread-lifecycle.ts +2 -0
  40. package/dist/Context-DueQ9iMH.js +0 -4916
  41. package/dist/Path-D8WPdPwR.js +0 -11406
  42. package/dist/Schema-DsQxYh6_.js +0 -13581
  43. package/dist/UrlParams-BA6gBvaY.js +0 -205
  44. package/dist/base-dir-R12OMDso.js +0 -20
  45. package/dist/error-BHRnjLux.js +0 -15
  46. package/dist/error-ChTsLQTu.js +0 -169
  47. package/dist/error-CjADNVm8.js +0 -54
  48. package/dist/flags-CM7_iGdA.js +0 -13371
  49. package/dist/layer-CMo36MrX.js +0 -29466
  50. package/dist/layer-DHhKS5jd.js +0 -13
  51. package/dist/layer-DUv99vsS.js +0 -72
  52. package/dist/layer-Do8wK1t6.js +0 -1508
  53. package/dist/layer-DwI3Skkh.js +0 -1223
  54. package/dist/scope-GycYiJ54.js +0 -29
  55. package/dist/service-CLmRO2Dp.js +0 -8
  56. package/dist/service-ybOWV9pL.js +0 -5
  57. package/dist/src-aiFPmAG7.js +0 -9030
  58. package/dist/transport-MI0Z3d2T.js +0 -539
  59. package/dist/url-SlsaG8nY.js +0 -165
  60. /package/dist/{chunk-B5meny8j.js → rolldown-runtime.js} +0 -0
@@ -1,4 +1,6 @@
1
1
  import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { ProviderUserInputAnswers } from "#t3tools/contracts";
2
4
 
3
5
  import { MessageInputError } from "./error.ts";
4
6
  import type { InputError } from "./input/error.ts";
@@ -23,3 +25,31 @@ export function readInitialMessage(input: {
23
25
  }
24
26
  return Effect.succeed(input.message);
25
27
  }
28
+
29
+ export function readJsonAnswers(input: {
30
+ readonly answers: string | undefined;
31
+ readonly fromStdin: boolean;
32
+ readonly readStdin: () => Effect.Effect<string, InputError>;
33
+ }) {
34
+ if (input.answers !== undefined && input.answers.length > 0 && input.fromStdin) {
35
+ return Effect.fail(new MessageInputError({ message: "pass --answers or --stdin, not both" }));
36
+ }
37
+ if (input.fromStdin) {
38
+ return input.readStdin().pipe(Effect.flatMap(parseJsonAnswers));
39
+ }
40
+ if (input.answers === undefined || input.answers.length === 0) {
41
+ return Effect.fail(
42
+ new MessageInputError({ message: "answers required unless --stdin is used" }),
43
+ );
44
+ }
45
+ return parseJsonAnswers(input.answers);
46
+ }
47
+
48
+ function parseJsonAnswers(text: string) {
49
+ try {
50
+ const parsed: unknown = JSON.parse(text);
51
+ return Effect.succeed(Schema.decodeUnknownSync(ProviderUserInputAnswers)(parsed));
52
+ } catch {
53
+ return Effect.fail(new MessageInputError({ message: "invalid JSON in answers" }));
54
+ }
55
+ }
@@ -1,7 +1,54 @@
1
+ import type { ThreadShow } from "../application/threads.ts";
1
2
  import type { WaitEvent } from "../application/service.ts";
2
3
  import type { OrchestrationThread, OrchestrationThreadShell } from "#t3tools/contracts";
3
4
  import { latestAssistantMessage, threadStatus } from "../domain/thread-lifecycle.ts";
4
5
 
6
+ export function formatThreadShowJson(thread: ThreadShow) {
7
+ return {
8
+ id: thread.id,
9
+ projectId: thread.projectId,
10
+ title: thread.title,
11
+ status: thread.status,
12
+ session: thread.session,
13
+ latestTurn: thread.latestTurn,
14
+ modelSelection: thread.modelSelection,
15
+ runtimeMode: thread.runtimeMode,
16
+ interactionMode: thread.interactionMode,
17
+ branch: thread.branch,
18
+ worktreePath: thread.worktreePath,
19
+ archivedAt: thread.archivedAt,
20
+ createdAt: thread.createdAt,
21
+ updatedAt: thread.updatedAt,
22
+ messageCount: thread.messageCount,
23
+ hasPendingApprovals: thread.hasPendingApprovals,
24
+ hasPendingUserInput: thread.hasPendingUserInput,
25
+ hasActionableProposedPlan: thread.hasActionableProposedPlan,
26
+ pendingApprovals: thread.pendingApprovals,
27
+ pendingUserInputs: thread.pendingUserInputs,
28
+ };
29
+ }
30
+
31
+ export function formatThreadShowHuman(thread: ThreadShow) {
32
+ const lines = [
33
+ `title: ${thread.title}`,
34
+ `id: ${thread.id}`,
35
+ `status: ${thread.status}`,
36
+ `model: ${thread.modelSelection.instanceId}/${thread.modelSelection.model}`,
37
+ ...(thread.branch !== null ? [`branch: ${thread.branch}`] : []),
38
+ ...(thread.worktreePath !== null ? [`worktree: ${thread.worktreePath}`] : []),
39
+ `messages: ${thread.messageCount}`,
40
+ ];
41
+ if (thread.pendingApprovals.length > 0) {
42
+ const requestIds = thread.pendingApprovals.map((approval) => approval.requestId).join(", ");
43
+ lines.push(`pending approvals: ${thread.pendingApprovals.length} (${requestIds})`);
44
+ }
45
+ if (thread.pendingUserInputs.length > 0) {
46
+ const requestIds = thread.pendingUserInputs.map((input) => input.requestId).join(", ");
47
+ lines.push(`pending user inputs: ${thread.pendingUserInputs.length} (${requestIds})`);
48
+ }
49
+ return `${lines.join("\n")}\n`;
50
+ }
51
+
5
52
  export function formatThreadsHuman(threads: ReadonlyArray<OrchestrationThreadShell>) {
6
53
  return threads
7
54
  .map(
package/src/cli/thread.ts CHANGED
@@ -1,10 +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";
4
5
  import { callbackThreadCommand } from "./threads/callback.ts";
5
6
  import { listThreadsCommand } from "./threads/list.ts";
6
7
  import { getThreadMessagesCommand } from "./threads/messages.ts";
8
+ import { respondThreadCommand } from "./threads/respond.ts";
7
9
  import { sendThreadCommand } from "./threads/send.ts";
10
+ import { showThreadCommand } from "./threads/show.ts";
8
11
  import { startThreadCommand } from "./threads/start.ts";
9
12
  import { waitForThreadCommand } from "./threads/wait.ts";
10
13
 
@@ -15,6 +18,9 @@ export function createThreadCommand() {
15
18
  listThreadsCommand,
16
19
  startThreadCommand,
17
20
  sendThreadCommand,
21
+ showThreadCommand,
22
+ approveThreadCommand,
23
+ respondThreadCommand,
18
24
  archiveThreadCommand,
19
25
  getThreadMessagesCommand,
20
26
  waitForThreadCommand,
@@ -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") {
@@ -67,8 +67,7 @@ export const callbackThreadCommand = Command.make(
67
67
  });
68
68
 
69
69
  const handle = yield* spawner.spawn(proc);
70
- const reref = yield* handle.unref;
71
- yield* reref.pipe(Effect.ignore);
70
+ yield* handle.unref.pipe(Effect.ignore);
72
71
 
73
72
  yield* output.printInfo(
74
73
  `background callback scheduled: ${fromThreadId} -> ${targetThreadId} (pid: ${handle.pid})`,
@@ -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,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
+ });