@saadnvd1/agent-os 0.3.744 → 0.3.749

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 (49) hide show
  1. package/README.md +1 -1
  2. package/app/api/models/choices/route.ts +7 -3
  3. package/app/api/sessions/[id]/model/route.ts +2 -23
  4. package/components/Analyst/StartAnalystSheet.tsx +7 -2
  5. package/components/Analyst/useAnalystModels.ts +19 -0
  6. package/components/Approvals/NoApprovalsBadge.tsx +22 -9
  7. package/components/Away/AwayToggle.tsx +5 -5
  8. package/components/Chat/ChatPanel.tsx +10 -2
  9. package/components/Chat/Messages.test.ts +33 -0
  10. package/components/Chat/Messages.tsx +15 -0
  11. package/components/Chat/ModelPicker.tsx +3 -2
  12. package/components/Chat/idle.ts +9 -0
  13. package/components/Draft/AnalystModelChip.tsx +58 -0
  14. package/components/Draft/DraftChips.tsx +8 -0
  15. package/components/Draft/DraftPanel.tsx +7 -1
  16. package/components/Draft/RoleChips.tsx +12 -3
  17. package/components/Models/AgentRoleCard.tsx +9 -2
  18. package/components/Models/RoleModelSelect.tsx +23 -8
  19. package/components/SessionList/MachineSwitcher.tsx +1 -1
  20. package/components/SessionList/ProjectFilter.tsx +1 -1
  21. package/components/SessionList/SidebarHeader.tsx +3 -3
  22. package/components/SessionList/SidebarSearch.tsx +1 -1
  23. package/components/SessionList/index.tsx +1 -1
  24. package/components/Settings/NoApprovalsSection.test.ts +5 -2
  25. package/lib/chat/demo.ts +23 -2
  26. package/lib/chat/drivers/demo-script.ts +30 -4
  27. package/lib/chat/drivers/opencode-config.ts +5 -0
  28. package/lib/chat/drivers/opencode-mapper.ts +104 -17
  29. package/lib/chat/drivers/opencode.ts +19 -1
  30. package/lib/chat/drivers/turn-outcome.ts +22 -1
  31. package/lib/chat/events.ts +12 -1
  32. package/lib/chat/group.ts +11 -2
  33. package/lib/chat/progress.ts +4 -0
  34. package/lib/chat/role-models.ts +46 -0
  35. package/lib/chat/runner-watch.ts +1 -1
  36. package/lib/chat/settings.ts +7 -4
  37. package/lib/chat/socket.ts +3 -2
  38. package/lib/chat/turn-end.ts +16 -0
  39. package/lib/drafts.ts +2 -0
  40. package/lib/models/agent-binding.ts +23 -2
  41. package/lib/models/catalog.ts +43 -1
  42. package/lib/models/providers.ts +12 -0
  43. package/lib/models/resolve.ts +3 -7
  44. package/lib/models/session-model.ts +70 -4
  45. package/lib/models/tool-use.ts +78 -0
  46. package/lib/models/usable.ts +45 -0
  47. package/lib/updates/check.ts +32 -0
  48. package/lib/updates/view.ts +2 -1
  49. package/package.json +1 -1
@@ -38,6 +38,7 @@ vi.mock("@/data/approvals", () => ({
38
38
  }),
39
39
  }));
40
40
 
41
+ const { TooltipProvider } = await import("@/components/ui/tooltip");
41
42
  const { NoApprovalsSection } = await import("./NoApprovalsSection");
42
43
  const { NoApprovalsBadge } =
43
44
  await import("@/components/Approvals/NoApprovalsBadge");
@@ -112,10 +113,12 @@ describe("the No approvals toggle", () => {
112
113
 
113
114
  describe("the badge", () => {
114
115
  it("shows only while the mode is on", () => {
115
- render(createElement(NoApprovalsBadge));
116
+ const badge = () =>
117
+ createElement(TooltipProvider, null, createElement(NoApprovalsBadge));
118
+ render(badge());
116
119
  expect(host.textContent).toBe("");
117
120
  state.on = true;
118
- render(createElement(NoApprovalsBadge));
121
+ render(badge());
119
122
  expect(host.textContent).toContain("No approvals");
120
123
  });
121
124
  });
package/lib/chat/demo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  ChatClientMessage,
12
12
  ChatServerMessage,
13
13
  ChatState,
14
+ Compacting,
14
15
  ModelWait,
15
16
  } from "./events";
16
17
  import { demoConversation } from "./drivers/demo";
@@ -31,7 +32,10 @@ const sends = new Map<string, number>();
31
32
 
32
33
  // What a demo chat is doing, for a watcher that connects mid-turn (the stored
33
34
  // items alone would show a running step as stopped).
34
- const live = new Map<string, { state: ChatState; wait: ModelWait | null }>();
35
+ const live = new Map<
36
+ string,
37
+ { state: ChatState; wait: ModelWait | null; compacting: Compacting | null }
38
+ >();
35
39
 
36
40
  /** The demo chat's running turn, or null with none. */
37
41
  export function demoLive(sessionId: string) {
@@ -46,6 +50,18 @@ function conversation(sessionId: string): ChatConversation {
46
50
  let tracker = new ModelWaitTracker();
47
51
  void (async () => {
48
52
  for await (const e of c.events) {
53
+ if (e.type === "compacting") {
54
+ // The tokens before are the scripted prompt's (drivers/demo-script).
55
+ const compacting = e.active
56
+ ? { since: Date.now(), before: 48_200 }
57
+ : null;
58
+ const state = live.get(sessionId);
59
+ if (state) state.compacting = compacting;
60
+ tracker.on({ type: "compacting", compacting } as WorkerEvent);
61
+ emit(sessionId, { type: "compacting", compacting });
62
+ emit(sessionId, { type: "model_wait", wait: tracker.wait });
63
+ continue;
64
+ }
49
65
  // Each turn follows its own waits, whatever the last one left.
50
66
  if (e.type === "state" && e.state === "running")
51
67
  tracker = new ModelWaitTracker();
@@ -53,7 +69,12 @@ function conversation(sessionId: string): ChatConversation {
53
69
  emit(sessionId, { type: "model_wait", wait: tracker.wait });
54
70
  if (e.type === "state") {
55
71
  if (e.state === "idle") live.delete(sessionId);
56
- else live.set(sessionId, { state: e.state, wait: tracker.wait });
72
+ else
73
+ live.set(sessionId, {
74
+ state: e.state,
75
+ wait: tracker.wait,
76
+ compacting: null,
77
+ });
57
78
  }
58
79
  const state = live.get(sessionId);
59
80
  if (state) state.wait = tracker.wait;
@@ -4,12 +4,17 @@
4
4
  * model, a retry) for a set time, then finishes. Demo only: the demo driver
5
5
  * is the one place that reads it (AGENTOS_DEMO=1; no real session gets here).
6
6
  *
7
- * demo: running turn [step|wait|retry] [hold=<seconds>]
7
+ * demo: running turn [step|wait|retry|compact|stalled|empty] [hold=<seconds>]
8
+ *
9
+ * compact holds a compaction; stalled and empty end the turn the way a stalled
10
+ * model request and an empty reply after compaction do (with a Retry).
8
11
  */
9
12
 
10
- import type { DriverEvent } from "../events";
13
+ import type { DriverEvent, TurnEndReason } from "../events";
14
+ import { COMPACTION_EMPTY_NOTICE, stalledNotice } from "./turn-outcome";
11
15
 
12
- export type DemoPhase = "step" | "wait" | "retry";
16
+ export type DemoPhase =
17
+ "step" | "wait" | "retry" | "compact" | "stalled" | "empty";
13
18
 
14
19
  export interface DemoScript {
15
20
  // Hold only this state; none runs all three, one after the other.
@@ -19,7 +24,8 @@ export interface DemoScript {
19
24
 
20
25
  export const DEFAULT_HOLD_S = 30;
21
26
  export const MAX_HOLD_S = 120;
22
- const TRIGGER = /^demo: running turn(?: (step|wait|retry))?(?: hold=(\d+))?$/;
27
+ const TRIGGER =
28
+ /^demo: running turn(?: (step|wait|retry|compact|stalled|empty))?(?: hold=(\d+))?$/;
23
29
 
24
30
  export function parseDemoScript(text: string): DemoScript | null {
25
31
  const m = TRIGGER.exec(text.trim());
@@ -99,6 +105,13 @@ async function runningStep(run: ScriptRun, id: string, holdMs: number) {
99
105
  });
100
106
  }
101
107
 
108
+ // A compaction of a big context, held, then done.
109
+ async function compacting(run: ScriptRun, holdMs: number) {
110
+ run.push({ type: "compacting", active: true });
111
+ await hold(run, holdMs);
112
+ run.push({ type: "compacting", active: false });
113
+ }
114
+
102
115
  export async function runDemoScript(
103
116
  script: DemoScript,
104
117
  run: ScriptRun,
@@ -108,6 +121,13 @@ export async function runDemoScript(
108
121
  const phases: DemoPhase[] = script.phase
109
122
  ? [script.phase]
110
123
  : ["step", "wait", "retry"];
124
+ // The end of a turn that stopped short, after one finished step.
125
+ const ending: { reason: TurnEndReason; notice: string } | undefined =
126
+ script.phase === "stalled"
127
+ ? { reason: "stalled", notice: stalledNotice(22 * 60_000, false) }
128
+ : script.phase === "empty"
129
+ ? { reason: "compaction_empty", notice: COMPACTION_EMPTY_NOTICE }
130
+ : undefined;
111
131
  run.push({ type: "state", state: "running" });
112
132
  run.push({ type: "turn_start" });
113
133
  run.push({
@@ -124,6 +144,11 @@ export async function runDemoScript(
124
144
  if (run.stopped()) break;
125
145
  if (phase === "step")
126
146
  await runningStep(run, `demo-t-${runId}`, script.holdMs);
147
+ else if (phase === "stalled" || phase === "empty") {
148
+ await runningStep(run, `demo-t-${runId}`, 0);
149
+ if (phase === "empty") await compacting(run, script.holdMs);
150
+ else await hold(run, script.holdMs);
151
+ } else if (phase === "compact") await compacting(run, script.holdMs);
127
152
  else {
128
153
  if (phase === "retry")
129
154
  run.push({
@@ -141,6 +166,7 @@ export async function runDemoScript(
141
166
  kind: "turn_end",
142
167
  durationMs: Date.now() - started,
143
168
  interrupted: run.stopped() || undefined,
169
+ ended: run.stopped() ? undefined : ending,
144
170
  createdAt: Date.now(),
145
171
  },
146
172
  });
@@ -13,6 +13,11 @@ type P = Record<string, unknown>;
13
13
  // that never answers still ends the request.
14
14
  export const HEADER_TIMEOUT_MS = 1_200_000;
15
15
 
16
+ // How long a turn may wait on the model with no sign of life before AgentOS
17
+ // stops it. Just past one header timeout: OpenCode would retry the same
18
+ // stall up to five more times, hours on a local model.
19
+ export const STALL_MS = HEADER_TIMEOUT_MS + 120_000;
20
+
16
21
  const isObject = (v: unknown): v is P =>
17
22
  !!v && typeof v === "object" && !Array.isArray(v);
18
23
 
@@ -2,7 +2,13 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { openCodeTool } from "./opencode-rules";
4
4
  import { TurnItems } from "./turn-items";
5
- import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
5
+ import {
6
+ logTurnEnd,
7
+ needsSummary,
8
+ stalledNotice,
9
+ turnOutcome,
10
+ type TurnEnded,
11
+ } from "./turn-outcome";
6
12
  import { retryNotice, TurnRetry, type RetryDecision } from "./transient-retry";
7
13
 
8
14
  type P = Record<string, unknown>;
@@ -12,6 +18,9 @@ const num = (v: unknown) => Number(v) || 0;
12
18
  // A prompt this big (tokens) is worth compacting before the last retry.
13
19
  const HEAVY_TOKENS = 16_000;
14
20
 
21
+ // Events that show the session is alive; a retry notice is not one.
22
+ const PROGRESS = /^(message\.|session\.(status|compacted|error)|todo\.)/;
23
+
15
24
  const TODO = new Set(["pending", "in_progress", "completed"]);
16
25
 
17
26
  // OpenCode's event stream as chat items, for one session (its subagents'
@@ -63,6 +72,15 @@ export class OpenCodeMapper {
63
72
  lastInputTokens = 0;
64
73
  // Compacting between retries makes the session busy and idle: not a turn.
65
74
  private holding = false;
75
+ // OpenCode is compacting the context (its summary request is out).
76
+ private compacting = false;
77
+ // A compaction ran in this turn; the messages that are its summary.
78
+ private compacted = false;
79
+ private summaries = new Set<string>();
80
+ // Tool calls not finished yet: the turn waits on them, not on the model.
81
+ private open = new Set<string>();
82
+ // When the session last did anything: the stall watchdog's clock.
83
+ private progressAt = Date.now();
66
84
 
67
85
  constructor(
68
86
  private sessionId: () => string | null,
@@ -84,12 +102,26 @@ export class OpenCodeMapper {
84
102
  this.agentRetried = false;
85
103
  }
86
104
  this.admitted = false;
105
+ this.progressAt = Date.now();
87
106
  this.items.startTurn();
88
107
  }
89
108
 
109
+ // How long the turn has waited on the model without a sign of life; 0
110
+ // while it isn't waiting on one (a tool runs, a retry is queued).
111
+ silentMs(now = Date.now()): number {
112
+ if (!this.items.inTurn || this.holding || this.retrying || this.open.size)
113
+ return 0;
114
+ return Math.max(0, now - this.progressAt);
115
+ }
116
+
90
117
  map(e: P): void {
91
118
  const p = (e.properties ?? {}) as P;
92
119
  if (p.sessionID && p.sessionID !== this.sessionId()) return;
120
+ if (
121
+ PROGRESS.test(e.type as string) &&
122
+ !(e.type === "session.status" && str((p.status as P)?.type) === "retry")
123
+ )
124
+ this.progressAt = Date.now();
93
125
  switch (e.type) {
94
126
  case "session.status": {
95
127
  const type = str((p.status as P)?.type);
@@ -113,6 +145,8 @@ export class OpenCodeMapper {
113
145
  case "session.error":
114
146
  return this.failed(p.error as P | undefined);
115
147
  case "session.compacted":
148
+ this.compacted = true;
149
+ this.setCompacting(false);
116
150
  return this.emit({
117
151
  type: "item",
118
152
  item: { id: randomUUID(), kind: "compacted", createdAt: Date.now() },
@@ -144,7 +178,15 @@ export class OpenCodeMapper {
144
178
  this.current.add(id);
145
179
  this.roles.set(id, str(info.role));
146
180
  if (info.role !== "assistant") return;
147
- if (this.last.id !== id)
181
+ const summary =
182
+ info.summary === true ||
183
+ info.mode === "compaction" ||
184
+ info.agent === "compaction";
185
+ if (summary) {
186
+ this.summaries.add(id);
187
+ this.setCompacting(!(info.time as P)?.completed && !info.error);
188
+ }
189
+ if (!summary && this.last.id !== id)
148
190
  this.last = {
149
191
  id,
150
192
  finish: "",
@@ -153,9 +195,11 @@ export class OpenCodeMapper {
153
195
  text: new Map(),
154
196
  calls: new Set(),
155
197
  };
156
- this.last.finish = str(info.finish) || this.last.finish;
157
- this.last.provider = str(info.providerID) || this.last.provider;
158
- this.last.model = str(info.modelID) || this.last.model;
198
+ if (!summary) {
199
+ this.last.finish = str(info.finish) || this.last.finish;
200
+ this.last.provider = str(info.providerID) || this.last.provider;
201
+ this.last.model = str(info.modelID) || this.last.model;
202
+ }
159
203
  if (info.error) this.failed(info.error as P);
160
204
  if (!(info.time as P)?.completed || this.counted.has(id)) return;
161
205
  this.counted.add(id);
@@ -181,8 +225,12 @@ export class OpenCodeMapper {
181
225
  const id = str(part.id);
182
226
  if (this.past.has(id) || this.past.has(str(part.messageID))) return;
183
227
  this.current.add(id);
228
+ // A compaction starts as a part of the message that asks for it.
229
+ if (part.type === "compaction") return this.setCompacting(true);
184
230
  // Only the agent's own messages: the prompt comes back as a part too.
185
231
  if (this.roles.get(str(part.messageID)) !== "assistant") return;
232
+ // The summary is the agent's memory, not part of the reply.
233
+ if (this.summaries.has(str(part.messageID))) return;
186
234
  if (part.type === "text" || part.type === "reasoning") {
187
235
  if (part.synthetic || part.ignored) return;
188
236
  const kind = part.type === "text" ? "assistant" : "reasoning";
@@ -200,6 +248,11 @@ export class OpenCodeMapper {
200
248
  }
201
249
  if (part.type === "tool") {
202
250
  this.toolIds.add(id);
251
+ const done = ["completed", "error"].includes(
252
+ str((part.state as P | undefined)?.status)
253
+ );
254
+ if (done) this.open.delete(id);
255
+ else this.open.add(id);
203
256
  if (this.last.id === str(part.messageID)) this.last.calls.add(id);
204
257
  this.tool(id, part);
205
258
  }
@@ -234,8 +287,15 @@ export class OpenCodeMapper {
234
287
  this.items.toolDone(id, "error", str(state.error), start);
235
288
  }
236
289
 
290
+ private setCompacting(on: boolean): void {
291
+ if (on === this.compacting) return;
292
+ this.compacting = on;
293
+ this.emit({ type: "compacting", active: on });
294
+ }
295
+
237
296
  private failed(error: P | undefined): void {
238
297
  if (!error) return;
298
+ this.setCompacting(false);
239
299
  if (error.name === "MessageAbortedError") this.aborted = true;
240
300
  else
241
301
  this.error =
@@ -268,6 +328,9 @@ export class OpenCodeMapper {
268
328
  for (const id of this.current) this.past.add(id);
269
329
  this.current.clear();
270
330
  this.parts.clear();
331
+ this.open.clear();
332
+ this.summaries.clear();
333
+ this.compacted = false;
271
334
  }
272
335
 
273
336
  // A failed turn is waiting for its retry to go out.
@@ -287,6 +350,7 @@ export class OpenCodeMapper {
287
350
 
288
351
  private idle(): void {
289
352
  if (this.holding || !this.admitted || !this.items.inTurn) return;
353
+ this.setCompacting(false);
290
354
  if (this.error && !this.aborted && this.retryTurn()) return;
291
355
  this.emit({ type: "usage", totals: { ...this.totals } });
292
356
  const text = [...this.last.text.values()].join("\n");
@@ -308,18 +372,26 @@ export class OpenCodeMapper {
308
372
  console.info("[chat] asked the model for its results summary");
309
373
  return;
310
374
  }
311
- const ended =
312
- this.aborted || !this.last.id
313
- ? undefined
314
- : turnOutcome({
315
- finishReason: this.last.finish,
316
- text,
317
- toolCalls: this.last.calls.size,
318
- error: this.error ?? undefined,
319
- retries: this.retry.tried,
320
- agentRetried: this.agentRetried,
321
- summary: this.items.summary(),
322
- });
375
+ const ended = this.aborted
376
+ ? undefined
377
+ : turnOutcome({
378
+ finishReason: this.last.finish,
379
+ text,
380
+ toolCalls: this.last.calls.size,
381
+ compacted: this.compacted,
382
+ toolsDone: /tool/i.test(this.last.finish) && !this.open.size,
383
+ error: this.error ?? undefined,
384
+ retries: this.retry.tried,
385
+ agentRetried: this.agentRetried,
386
+ summary: this.items.summary(),
387
+ });
388
+ this.finish(ended, text);
389
+ this.clearTurn();
390
+ this.nudged = this.nudgeDue = false;
391
+ }
392
+
393
+ // The turn is over: the end told, then the state reset.
394
+ private finish(ended: TurnEnded | undefined, text: string): void {
323
395
  if (ended)
324
396
  logTurnEnd({
325
397
  driver: "opencode",
@@ -335,6 +407,21 @@ export class OpenCodeMapper {
335
407
  finishReason: this.last.finish || undefined,
336
408
  ended,
337
409
  });
410
+ }
411
+
412
+ // The model stayed silent past the limit: the driver stops the session and
413
+ // the turn ends here, with what it got through kept.
414
+ stalled(ms: number): void {
415
+ if (!this.items.inTurn) return;
416
+ const compacting = this.compacting;
417
+ this.setCompacting(false);
418
+ this.retrying = false;
419
+ this.emit({ type: "usage", totals: { ...this.totals } });
420
+ const ended: TurnEnded = {
421
+ reason: "stalled",
422
+ notice: stalledNotice(ms, compacting),
423
+ };
424
+ this.finish(ended, "");
338
425
  this.clearTurn();
339
426
  this.nudged = this.nudgeDue = false;
340
427
  }
@@ -8,7 +8,7 @@ import { openCodeRules } from "./opencode-rules";
8
8
  import { OpenCodeServer } from "./opencode-server";
9
9
  import { OpenCodeApprovals } from "./opencode-approvals";
10
10
  import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
11
- import { openCodeConfigContent } from "./opencode-config";
11
+ import { openCodeConfigContent, STALL_MS } from "./opencode-config";
12
12
  import { writeOpenCodeGuard } from "./opencode-plugin";
13
13
  import { typescriptPath } from "./edit-guard";
14
14
  import { continuePrompt, type RetryDecision } from "./transient-retry";
@@ -44,6 +44,9 @@ function conversationEnv(
44
44
  }
45
45
  }
46
46
 
47
+ // How often a waiting turn is checked for silence.
48
+ const WATCH_MS = 30_000;
49
+
47
50
  // How long a prompt may go without the session turning busy before the
48
51
  // turn is checked on.
49
52
  const ADMIT_MS = 15_000;
@@ -142,6 +145,20 @@ export const openCodeDriver: ChatDriver = {
142
145
  const held: { text: string; images?: ChatImage[] }[] = [];
143
146
  const stream = new AbortController();
144
147
  let closed = false;
148
+ // A turn silent for STALL_MS (an env override is for tests only) is
149
+ // stopped: OpenCode's own retries could wait for hours.
150
+ const stallMs = Number(process.env.AGENTOS_OPENCODE_STALL_MS) || STALL_MS;
151
+ const watchdog = setInterval(
152
+ () => {
153
+ const silent = mapper.silentMs();
154
+ if (silent < stallMs) return;
155
+ cancelRetry();
156
+ mapper.stalled(silent);
157
+ void abort();
158
+ },
159
+ Math.min(WATCH_MS, stallMs / 2)
160
+ );
161
+ watchdog.unref();
145
162
 
146
163
  const fail = (error: Error) => mapper.items.error(error.message);
147
164
  const ours = (id: unknown) => id === sessionId || children.has(str(id));
@@ -286,6 +303,7 @@ export const openCodeDriver: ChatDriver = {
286
303
  function close() {
287
304
  if (closed) return;
288
305
  closed = true;
306
+ clearInterval(watchdog);
289
307
  cancelRetry();
290
308
  approvals.pending.expireAll();
291
309
  stream.abort();
@@ -80,10 +80,26 @@ export function failureReport(o: {
80
80
  return `The model's server ${kind === "timeout" ? "timed out" : "kept failing"} (${why})${tries}. ${done} Not finished: the model's next reply, so the task is incomplete. Send "continue" to resume; the finished steps are kept.`;
81
81
  }
82
82
 
83
+ export const COMPACTION_EMPTY_NOTICE =
84
+ 'The model returned nothing after compaction, so the task is incomplete. Retry sends "continue"; the finished steps are kept.';
85
+
86
+ export const CUT_OFF_NOTICE =
87
+ 'The turn stopped right after a tool step, before the model replied, so the task may be incomplete. Retry sends "continue"; the finished steps are kept.';
88
+
89
+ // Said when a model request got no answer for the whole stall limit.
90
+ export function stalledNotice(ms: number, compacting: boolean): string {
91
+ const min = Math.max(1, Math.round(ms / 60_000));
92
+ return `${compacting ? "The model didn't finish compacting the context" : "Model request stalled"}: no response for ${plural(min, "minute")}, so AgentOS stopped waiting. The finished steps are kept. Retry sends "continue".`;
93
+ }
94
+
83
95
  export function turnOutcome(r: {
84
96
  finishReason?: string;
85
97
  text: string;
86
98
  toolCalls: number;
99
+ // A compaction ran in this turn.
100
+ compacted?: boolean;
101
+ // Every tool call of the turn has finished.
102
+ toolsDone?: boolean;
87
103
  error?: string;
88
104
  retries?: number;
89
105
  agentRetried?: boolean;
@@ -107,7 +123,12 @@ export function turnOutcome(r: {
107
123
  })
108
124
  : "The model's server returned an error, shown above.",
109
125
  };
110
- if (r.toolCalls > 0) return undefined;
126
+ if (r.toolCalls > 0)
127
+ return r.toolsDone
128
+ ? { reason: "cut_off", notice: CUT_OFF_NOTICE }
129
+ : undefined;
130
+ if (r.compacted && !r.text.trim())
131
+ return { reason: "compaction_empty", notice: COMPACTION_EMPTY_NOTICE };
111
132
  if (hasToolMarkup(r.text))
112
133
  return {
113
134
  reason: "parse_failure",
@@ -27,6 +27,8 @@ export interface ChatModel {
27
27
  value: string;
28
28
  label: string;
29
29
  description?: string;
30
+ // Why it can't be picked now; it is listed greyed with this.
31
+ disabled?: string;
30
32
  }
31
33
 
32
34
  export type ToolStatus = "running" | "done" | "error" | "stopped";
@@ -151,7 +153,16 @@ interface Base {
151
153
  }
152
154
 
153
155
  export type TurnEndReason =
154
- "no_tool_call" | "length" | "parse_failure" | "empty" | "error";
156
+ | "no_tool_call"
157
+ | "length"
158
+ | "parse_failure"
159
+ | "empty"
160
+ | "error"
161
+ // The model gave no reply for too long, or none after a compaction, or
162
+ // the turn stopped right after a tool step.
163
+ | "stalled"
164
+ | "compaction_empty"
165
+ | "cut_off";
155
166
 
156
167
  export type ChatItem =
157
168
  | (Base & {
package/lib/chat/group.ts CHANGED
@@ -1,8 +1,16 @@
1
- import type { ChatItem } from "./events";
1
+ import type { ChatItem, TurnEndReason } from "./events";
2
2
  import { readingOrder } from "./reading-order";
3
3
  import { stepCounts, type StepCounts } from "./tool-outcome";
4
4
  import { eventTurnAt, type EventTurn } from "./turns";
5
5
 
6
+ // Ends that stop a turn mid-task: they show how many steps got done.
7
+ const CUT_SHORT = new Set<TurnEndReason>([
8
+ "error",
9
+ "stalled",
10
+ "compaction_empty",
11
+ "cut_off",
12
+ ]);
13
+
6
14
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
7
15
  export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
8
16
 
@@ -99,7 +107,8 @@ export function groupTimeline(
99
107
  flush();
100
108
  const cut =
101
109
  item.kind === "turn_end" &&
102
- (item.interrupted || item.ended?.reason === "error");
110
+ (item.interrupted ||
111
+ (item.ended !== undefined && CUT_SHORT.has(item.ended.reason)));
103
112
  blocks.push(
104
113
  cut && turnTools.length
105
114
  ? { type: "item", item, steps: stepCounts(turnTools) }
@@ -7,6 +7,8 @@ export type Phase = "planning" | "coding" | "testing" | "error";
7
7
 
8
8
  // A wait on one model call this long gets a hint that it may be normal.
9
9
  export const LONG_WAIT_MS = 2 * 60_000;
10
+ export const LONG_COMPACT_HINT =
11
+ "Compacting a large context on a local model can take minutes.";
10
12
  export const LONG_WAIT_HINT =
11
13
  "Still waiting on the model; large prompts on local models can take minutes";
12
14
 
@@ -118,6 +120,8 @@ export function currentActivity(
118
120
  compacting: true,
119
121
  kind: "compacting",
120
122
  steps: 0,
123
+ hint:
124
+ now - compacting.since > LONG_WAIT_MS ? LONG_COMPACT_HINT : undefined,
121
125
  };
122
126
  const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
123
127
  const tools = turnTools(items);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The model list in a role session's composer. A chat's own list is whatever
3
+ * its agent reports (for Claude Code, only Claude's aliases), so an analyst
4
+ * would never be offered Qwen, Pi's models or DeepSeek. An analyst's list is
5
+ * the server's one catalog instead, with each model it can't move to greyed
6
+ * and given the reason.
7
+ */
8
+
9
+ import type { Session } from "../db";
10
+ import { modelDisplayName } from "../model-catalog";
11
+ import { modelCatalog, unconfiguredChoices } from "../models/catalog";
12
+ import { PI } from "../models/providers";
13
+ import { sessionModelRef } from "../models/resolve";
14
+ import { ANTHROPIC } from "../models/roles";
15
+ import { analystBlock } from "../models/usable";
16
+ import type { ChatModel } from "./events";
17
+
18
+ const isAnalystChat = (s: Session) =>
19
+ s.agent_role === "analyst" &&
20
+ (s.agent_type === "claude" || s.agent_type === "pi");
21
+
22
+ // The session's model as a ref, which is what its list's values are.
23
+ export const composerModelValue = (s: Session): string =>
24
+ s.agent_type === "pi" ? `${PI}/${s.model}` : sessionModelRef(s);
25
+
26
+ export function roleComposerModels(
27
+ session: Session,
28
+ own: ChatModel[]
29
+ ): ChatModel[] {
30
+ if (!isAnalystChat(session)) return own;
31
+ const agent = session.agent_type === "pi" ? "pi" : "claude";
32
+ const list = [...modelCatalog(), ...unconfiguredChoices()].map(
33
+ (c): ChatModel => {
34
+ const disabled = analystBlock(c, { agent });
35
+ return {
36
+ value: c.ref,
37
+ label: c.ref.startsWith(`${ANTHROPIC}/`)
38
+ ? modelDisplayName(c.label)
39
+ : c.label,
40
+ description: c.group,
41
+ ...(disabled && { disabled }),
42
+ };
43
+ }
44
+ );
45
+ return list.length ? list : own;
46
+ }
@@ -44,7 +44,7 @@ export function watchChat(sessionId: string, listener: Listener): () => void {
44
44
  hasMore: page.hasMore,
45
45
  tasks: present(sessionId, tasks),
46
46
  state: live?.state ?? demo?.state ?? "idle",
47
- compacting: live?.compacting ?? null,
47
+ compacting: live?.compacting ?? demo?.compacting ?? null,
48
48
  modelWait: live?.modelWait ?? demo?.wait ?? null,
49
49
  queue: listQueue(sessionId),
50
50
  suggestion: getSession(sessionId).chat_suggestion ?? null,
@@ -9,6 +9,7 @@ import { db, type Session } from "../db";
9
9
  import { agentEnv } from "../agents/launch";
10
10
  import { modelDisplayName, resolveModelForAgent } from "../model-catalog";
11
11
  import { chatDriverFor } from "./drivers";
12
+ import { composerModelValue, roleComposerModels } from "./role-models";
12
13
  import {
13
14
  CHAT_ACCESS,
14
15
  type ChatAccess,
@@ -75,15 +76,17 @@ export function cachedCommands(session: Session): ChatCommand[] {
75
76
  export function emitCapabilities(session: Session, listener?: Listener): void {
76
77
  const caps = registry.caps.get(capsKey(session));
77
78
  if (!caps) return;
78
- const model = chatModel(session);
79
+ const models = roleComposerModels(session, caps.models);
80
+ const model =
81
+ models === caps.models ? chatModel(session) : composerModelValue(session);
79
82
  const m: ChatServerMessage = {
80
83
  type: "capabilities",
81
84
  commands: visibleCommands(caps),
82
- models: caps.models,
85
+ models,
83
86
  model,
84
87
  modelLabel:
85
- caps.models.find((o) => o.value === model)?.label ??
86
- modelDisplayName(model),
88
+ models.find((o) => o.value === model)?.label ??
89
+ modelDisplayName(chatModel(session)),
87
90
  access: effectiveChatAccess(session.chat_access),
88
91
  plan: canPlan(session) ? !!session.chat_plan && !noApprovalsOn() : null,
89
92
  background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
@@ -24,12 +24,12 @@ import {
24
24
  sendChat,
25
25
  sendQueuedNow,
26
26
  setChatAccess,
27
- setChatModel,
28
27
  setChatPlan,
29
28
  stopChatTask,
30
29
  undoChat,
31
30
  watchChat,
32
31
  } from "./runner";
32
+ import { setComposerModel } from "../models/session-model";
33
33
  import { stopChatTurn } from "./stop";
34
34
  import { clientSend } from "./client-send";
35
35
  import { searchChat } from "./search";
@@ -189,7 +189,8 @@ function onChatMessage(
189
189
  .then(answer)
190
190
  );
191
191
  } else if (msg.type === "interrupt") run(stopChatTurn(sessionId));
192
- else if (msg.type === "set_model") run(setChatModel(sessionId, msg.model));
192
+ else if (msg.type === "set_model")
193
+ run(setComposerModel(sessionId, msg.model));
193
194
  else if (msg.type === "set_access") run(setChatAccess(sessionId, msg.access));
194
195
  else if (msg.type === "set_plan") run(setChatPlan(sessionId, !!msg.plan));
195
196
  else if (msg.type === "refresh_commands") run(refreshCapabilities(sessionId));