@saadnvd1/agent-os 0.3.744 → 0.3.748
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.
- package/components/Approvals/NoApprovalsBadge.tsx +22 -9
- package/components/Away/AwayToggle.tsx +5 -5
- package/components/Chat/ChatPanel.tsx +10 -2
- package/components/Chat/Messages.test.ts +33 -0
- package/components/Chat/Messages.tsx +15 -0
- package/components/Chat/idle.ts +9 -0
- package/components/SessionList/MachineSwitcher.tsx +1 -1
- package/components/SessionList/ProjectFilter.tsx +1 -1
- package/components/SessionList/SidebarHeader.tsx +3 -3
- package/components/SessionList/SidebarSearch.tsx +1 -1
- package/components/SessionList/index.tsx +1 -1
- package/components/Settings/NoApprovalsSection.test.ts +5 -2
- package/lib/chat/demo.ts +23 -2
- package/lib/chat/drivers/demo-script.ts +30 -4
- package/lib/chat/drivers/opencode-config.ts +5 -0
- package/lib/chat/drivers/opencode-mapper.ts +104 -17
- package/lib/chat/drivers/opencode.ts +19 -1
- package/lib/chat/drivers/turn-outcome.ts +22 -1
- package/lib/chat/events.ts +10 -1
- package/lib/chat/group.ts +11 -2
- package/lib/chat/progress.ts +4 -0
- package/lib/chat/runner-watch.ts +1 -1
- package/lib/chat/turn-end.ts +16 -0
- package/lib/updates/check.ts +32 -0
- package/lib/updates/view.ts +2 -1
- package/package.json +1 -1
|
@@ -3,20 +3,33 @@
|
|
|
3
3
|
import { ShieldOff } from "lucide-react";
|
|
4
4
|
import { useApprovalsModeQuery } from "@/data/approvals";
|
|
5
5
|
import { settingsUiActions } from "@/stores/settingsUi";
|
|
6
|
+
import {
|
|
7
|
+
Tooltip,
|
|
8
|
+
TooltipContent,
|
|
9
|
+
TooltipTrigger,
|
|
10
|
+
} from "@/components/ui/tooltip";
|
|
6
11
|
|
|
7
12
|
// On screen for as long as No approvals mode is on; opens its Settings.
|
|
13
|
+
// Shrinks to the icon when the sidebar header is narrow.
|
|
8
14
|
export function NoApprovalsBadge() {
|
|
9
15
|
const { data } = useApprovalsModeQuery();
|
|
10
16
|
if (!data?.on) return null;
|
|
11
17
|
return (
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
<Tooltip>
|
|
19
|
+
<TooltipTrigger asChild>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
onClick={() => settingsUiActions.open("approvals")}
|
|
23
|
+
aria-label="No approvals mode is on. Open settings."
|
|
24
|
+
className="flex h-11 w-11 shrink-0 items-center justify-center gap-1 rounded-md bg-amber-500/20 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50 md:h-8 md:w-8 @[22rem]:w-auto @[22rem]:px-2 [@media(hover:none)]:md:h-11"
|
|
25
|
+
>
|
|
26
|
+
<ShieldOff className="h-3.5 w-3.5" />
|
|
27
|
+
<span className="hidden @[22rem]:inline">No approvals</span>
|
|
28
|
+
</button>
|
|
29
|
+
</TooltipTrigger>
|
|
30
|
+
<TooltipContent>
|
|
31
|
+
No approvals mode is on: nothing waits for a person. Open settings.
|
|
32
|
+
</TooltipContent>
|
|
33
|
+
</Tooltip>
|
|
21
34
|
);
|
|
22
35
|
}
|
|
@@ -39,16 +39,16 @@ export function AwayToggle() {
|
|
|
39
39
|
return (
|
|
40
40
|
<ADropdownMenu
|
|
41
41
|
minWidth="220px"
|
|
42
|
+
tooltip={
|
|
43
|
+
away
|
|
44
|
+
? `Away ${until}`
|
|
45
|
+
: "Away: let the orchestrators follow work through"
|
|
46
|
+
}
|
|
42
47
|
trigger={
|
|
43
48
|
<Button
|
|
44
49
|
variant="ghost"
|
|
45
50
|
size="icon-sm"
|
|
46
51
|
aria-label={away ? `Away ${until}` : "Away"}
|
|
47
|
-
title={
|
|
48
|
-
away
|
|
49
|
-
? `Away ${until}`
|
|
50
|
-
: "Away: let the orchestrators follow work through"
|
|
51
|
-
}
|
|
52
52
|
disabled={setAway.isPending}
|
|
53
53
|
className={cn(
|
|
54
54
|
"relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
|
|
@@ -56,7 +56,8 @@ import { McpServers } from "./McpServers";
|
|
|
56
56
|
import { ChatSessionId } from "./session";
|
|
57
57
|
import { ArtifactCard } from "./Artifact";
|
|
58
58
|
import { PlanCard } from "./PlanCard";
|
|
59
|
-
import { ChatIdle } from "./idle";
|
|
59
|
+
import { ChatIdle, RetryTurn } from "./idle";
|
|
60
|
+
import { RETRY_TEXT } from "@/lib/chat/turn-end";
|
|
60
61
|
import { ContextMeter } from "./ContextMeter";
|
|
61
62
|
import { SetupCard } from "./SetupCard";
|
|
62
63
|
import { UserTurn } from "./EventRow";
|
|
@@ -631,6 +632,11 @@ export function ChatPanel({
|
|
|
631
632
|
// Another conversation starts without the last one's find.
|
|
632
633
|
useEffect(() => closeFind, [sessionId, closeFind]);
|
|
633
634
|
const idle = !running && connected;
|
|
635
|
+
const lastEndId = items.findLast((i) => i.kind === "turn_end")?.id ?? null;
|
|
636
|
+
const retryTurn = useMemo(
|
|
637
|
+
() => ({ id: lastEndId, retry: () => send(RETRY_TEXT) }),
|
|
638
|
+
[lastEndId, send]
|
|
639
|
+
);
|
|
634
640
|
const actions: ItemActions = useMemo(
|
|
635
641
|
() => ({
|
|
636
642
|
respond,
|
|
@@ -691,7 +697,9 @@ export function ChatPanel({
|
|
|
691
697
|
</div>
|
|
692
698
|
<ChatSessionId.Provider value={sessionId}>
|
|
693
699
|
<ChatIdle.Provider value={idle}>
|
|
694
|
-
<
|
|
700
|
+
<RetryTurn.Provider value={retryTurn}>
|
|
701
|
+
<TimelineRows blocks={blocks} actions={actions} />
|
|
702
|
+
</RetryTurn.Provider>
|
|
695
703
|
</ChatIdle.Provider>
|
|
696
704
|
</ChatSessionId.Provider>
|
|
697
705
|
{pending.map((p) => (
|
|
@@ -2,6 +2,7 @@ import { createElement } from "react";
|
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
|
|
5
|
+
import { ChatIdle, RetryTurn } from "./idle";
|
|
5
6
|
|
|
6
7
|
const item = {
|
|
7
8
|
id: "a1",
|
|
@@ -131,3 +132,35 @@ describe("TurnEnd steps", () => {
|
|
|
131
132
|
expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
|
|
132
133
|
});
|
|
133
134
|
});
|
|
135
|
+
|
|
136
|
+
describe("TurnEnd retry", () => {
|
|
137
|
+
const end = (reason: string, id = "e") =>
|
|
138
|
+
({
|
|
139
|
+
id,
|
|
140
|
+
kind: "turn_end",
|
|
141
|
+
ended: { reason, notice: "It stalled." },
|
|
142
|
+
}) as never;
|
|
143
|
+
const html = (item: never, retry: { id: string | null }, idle = true) =>
|
|
144
|
+
renderToStaticMarkup(
|
|
145
|
+
createElement(
|
|
146
|
+
ChatIdle.Provider,
|
|
147
|
+
{ value: idle },
|
|
148
|
+
createElement(
|
|
149
|
+
RetryTurn.Provider,
|
|
150
|
+
{ value: { ...retry, retry: () => {} } },
|
|
151
|
+
createElement(TurnEnd, { item })
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
it("offers Retry on the latest stalled end while idle", () => {
|
|
157
|
+
expect(html(end("stalled"), { id: "e" })).toContain(">Retry<");
|
|
158
|
+
expect(html(end("compaction_empty"), { id: "e" })).toContain(">Retry<");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does not on an older end, a running chat, or an ending retry can't fix", () => {
|
|
162
|
+
expect(html(end("stalled"), { id: "later" })).not.toContain("Retry");
|
|
163
|
+
expect(html(end("stalled"), { id: "e" }, false)).not.toContain("Retry");
|
|
164
|
+
expect(html(end("length"), { id: "e" })).not.toContain("Retry");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { StepCounts } from "@/lib/chat/tool-outcome";
|
|
4
4
|
import { memo } from "react";
|
|
5
|
+
import { retryableEnd } from "@/lib/chat/turn-end";
|
|
6
|
+
import { useChatIdle, useRetryTurn } from "./idle";
|
|
5
7
|
import { useSnapshot } from "valtio";
|
|
6
8
|
import {
|
|
7
9
|
Check,
|
|
@@ -398,6 +400,10 @@ export function TurnEnd({
|
|
|
398
400
|
}) {
|
|
399
401
|
const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
|
|
400
402
|
const early = !item.interrupted && item.ended;
|
|
403
|
+
const idle = useChatIdle();
|
|
404
|
+
const retryTurn = useRetryTurn();
|
|
405
|
+
const canRetry =
|
|
406
|
+
!!early && idle && retryTurn.id === item.id && retryableEnd(early.reason);
|
|
401
407
|
const label = item.interrupted
|
|
402
408
|
? "Stopped"
|
|
403
409
|
: early
|
|
@@ -420,6 +426,15 @@ export function TurnEnd({
|
|
|
420
426
|
finish reason: {item.finishReason}
|
|
421
427
|
</span>
|
|
422
428
|
)}
|
|
429
|
+
{canRetry && (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={retryTurn.retry}
|
|
433
|
+
className="hover:bg-foreground/[0.06] mt-2 block min-h-11 rounded-md border border-current/30 px-3 text-sm font-medium md:min-h-8"
|
|
434
|
+
>
|
|
435
|
+
Retry
|
|
436
|
+
</button>
|
|
437
|
+
)}
|
|
423
438
|
</div>
|
|
424
439
|
)}
|
|
425
440
|
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
package/components/Chat/idle.ts
CHANGED
|
@@ -7,3 +7,12 @@ import { createContext, useContext } from "react";
|
|
|
7
7
|
// re-renders those buttons and not every row of a long conversation.
|
|
8
8
|
export const ChatIdle = createContext(true);
|
|
9
9
|
export const useChatIdle = () => useContext(ChatIdle);
|
|
10
|
+
|
|
11
|
+
// The latest turn's end and what retrying it does: only that end offers
|
|
12
|
+
// Retry, and only while the chat is idle. A context so the one turn ending
|
|
13
|
+
// doesn't re-render every row.
|
|
14
|
+
export const RetryTurn = createContext<{
|
|
15
|
+
id: string | null;
|
|
16
|
+
retry: () => void;
|
|
17
|
+
}>({ id: null, retry: () => {} });
|
|
18
|
+
export const useRetryTurn = () => useContext(RetryTurn);
|
|
@@ -32,7 +32,7 @@ export function MachineSwitcher() {
|
|
|
32
32
|
type="button"
|
|
33
33
|
aria-label={`Machine: ${current ? machineLabel(current) : "All machines"}`}
|
|
34
34
|
className={cn(
|
|
35
|
-
"
|
|
35
|
+
"text-muted-foreground hover:text-foreground bg-card flex min-h-11 max-w-full min-w-0 items-center gap-1.5 rounded-xl border border-transparent px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
|
|
36
36
|
current && "border-primary/40 bg-primary/10 text-primary"
|
|
37
37
|
)}
|
|
38
38
|
>
|
|
@@ -25,7 +25,7 @@ export function ProjectFilter({
|
|
|
25
25
|
<button
|
|
26
26
|
type="button"
|
|
27
27
|
className={cn(
|
|
28
|
-
"bg-card flex min-h-11 max-w-
|
|
28
|
+
"bg-card flex min-h-11 max-w-full min-w-0 shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
|
|
29
29
|
current && "text-primary"
|
|
30
30
|
)}
|
|
31
31
|
>
|
|
@@ -67,8 +67,8 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
67
67
|
const workspaceId = props.workspace?.id ?? null;
|
|
68
68
|
const schedules = useSchedulesGlance(workspaceId);
|
|
69
69
|
return (
|
|
70
|
-
<div className="flex flex-wrap items-center gap-x-1 px-3 pt-3 pb-2">
|
|
71
|
-
<div className="flex min-w-[9.5rem] flex-1 items-center">
|
|
70
|
+
<div className="@container flex flex-wrap items-center gap-x-1 gap-y-2 px-3 pt-3 pb-2">
|
|
71
|
+
<div className="flex min-w-[9.5rem] flex-1 basis-[9.5rem] items-center">
|
|
72
72
|
<WorkspaceSwitcher
|
|
73
73
|
workspaces={props.workspaces}
|
|
74
74
|
current={props.workspace}
|
|
@@ -190,7 +190,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
190
190
|
]}
|
|
191
191
|
/>
|
|
192
192
|
</div>
|
|
193
|
-
<div className="w-full empty:hidden">
|
|
193
|
+
<div className="flex w-full min-w-0 empty:hidden">
|
|
194
194
|
<MachineSwitcher />
|
|
195
195
|
</div>
|
|
196
196
|
</div>
|
|
@@ -7,7 +7,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
|
7
7
|
// sessions too.
|
|
8
8
|
export function SidebarSearch({ query }: { query: string }) {
|
|
9
9
|
return (
|
|
10
|
-
<label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-
|
|
10
|
+
<label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-[1_1_8rem] items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
|
|
11
11
|
<Search className="h-3.5 w-3.5 shrink-0" />
|
|
12
12
|
<input
|
|
13
13
|
type="search"
|
|
@@ -168,7 +168,7 @@ export function SessionList({
|
|
|
168
168
|
}
|
|
169
169
|
pinControls={pinControls}
|
|
170
170
|
/>
|
|
171
|
-
<div className="flex gap-2 px-3 pb-2">
|
|
171
|
+
<div className="flex flex-wrap gap-2 px-3 pb-2">
|
|
172
172
|
<SidebarSearch query={ui.query} />
|
|
173
173
|
<ProjectFilter
|
|
174
174
|
projects={data.workspaceProjects}
|
|
@@ -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
|
-
|
|
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(
|
|
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<
|
|
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
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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)
|
|
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",
|
package/lib/chat/events.ts
CHANGED
|
@@ -151,7 +151,16 @@ interface Base {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
export type TurnEndReason =
|
|
154
|
-
|
|
154
|
+
| "no_tool_call"
|
|
155
|
+
| "length"
|
|
156
|
+
| "parse_failure"
|
|
157
|
+
| "empty"
|
|
158
|
+
| "error"
|
|
159
|
+
// The model gave no reply for too long, or none after a compaction, or
|
|
160
|
+
// the turn stopped right after a tool step.
|
|
161
|
+
| "stalled"
|
|
162
|
+
| "compaction_empty"
|
|
163
|
+
| "cut_off";
|
|
155
164
|
|
|
156
165
|
export type ChatItem =
|
|
157
166
|
| (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 ||
|
|
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) }
|
package/lib/chat/progress.ts
CHANGED
|
@@ -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);
|
package/lib/chat/runner-watch.ts
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TurnEndReason } from "./events";
|
|
2
|
+
|
|
3
|
+
// The ends a Retry button can resume: the model's side failed or went quiet.
|
|
4
|
+
const RETRYABLE: TurnEndReason[] = [
|
|
5
|
+
"error",
|
|
6
|
+
"empty",
|
|
7
|
+
"stalled",
|
|
8
|
+
"compaction_empty",
|
|
9
|
+
"cut_off",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const retryableEnd = (reason: TurnEndReason) =>
|
|
13
|
+
RETRYABLE.includes(reason);
|
|
14
|
+
|
|
15
|
+
// What Retry sends: the steps that ran stay, the model carries on.
|
|
16
|
+
export const RETRY_TEXT = "continue";
|
package/lib/updates/check.ts
CHANGED
|
@@ -163,6 +163,38 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
|
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A cached answer was worked out against whatever HEAD was then. If the
|
|
168
|
+
* checkout has since moved (a pull by hand, a switch to another branch) and
|
|
169
|
+
* now contains the latest commit, there's nothing to update to.
|
|
170
|
+
*/
|
|
171
|
+
export async function reconcile(
|
|
172
|
+
check: CheckResult | null,
|
|
173
|
+
deps: Pick<CheckDeps, "git">
|
|
174
|
+
): Promise<CheckResult | null> {
|
|
175
|
+
if (!check?.available || !check.latest) return check;
|
|
176
|
+
try {
|
|
177
|
+
const head = (await deps.git(["rev-parse", "HEAD"])).trim();
|
|
178
|
+
if (head === check.current?.sha) return check;
|
|
179
|
+
const has = await deps
|
|
180
|
+
.git(["merge-base", "--is-ancestor", check.latest.sha, "HEAD"])
|
|
181
|
+
.then(
|
|
182
|
+
() => true,
|
|
183
|
+
() => false
|
|
184
|
+
);
|
|
185
|
+
return has
|
|
186
|
+
? {
|
|
187
|
+
...check,
|
|
188
|
+
available: false,
|
|
189
|
+
behind: 0,
|
|
190
|
+
current: { version: check.latest.version, sha: head },
|
|
191
|
+
}
|
|
192
|
+
: check;
|
|
193
|
+
} catch {
|
|
194
|
+
return check;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
let memo: CheckResult | null = null;
|
|
167
199
|
let inflight: Promise<CheckResult> | null = null;
|
|
168
200
|
|
package/lib/updates/view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// whether the caller may start an update.
|
|
3
3
|
|
|
4
4
|
import { isActivePhase, type UpdateView } from "./banner";
|
|
5
|
-
import { cachedCheck, checkForUpdate } from "./check";
|
|
5
|
+
import { cachedCheck, checkForUpdate, reconcile } from "./check";
|
|
6
6
|
import {
|
|
7
7
|
preflight,
|
|
8
8
|
readLog,
|
|
@@ -40,6 +40,7 @@ export async function updateView(
|
|
|
40
40
|
)
|
|
41
41
|
check = (await checkForUpdate({ force: true, always: true }, deps.check))
|
|
42
42
|
.result;
|
|
43
|
+
check = await reconcile(check, deps.check);
|
|
43
44
|
// The log can carry paths and remote URLs; only a device that may update sees it.
|
|
44
45
|
const run =
|
|
45
46
|
stored && (stored.phase === "failed" || stored.phase === "built")
|