tinker-agent 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -1
- package/README.md +13 -5
- package/package.json +6 -5
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/observation-text-log.ts +21 -0
- package/src/events/stdout-event-printer.ts +11 -0
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +93 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +11 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash.ts +31 -0
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +47 -3
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +62 -3
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +18 -0
|
@@ -3,9 +3,11 @@ import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
|
3
3
|
export type FooterProps = {
|
|
4
4
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
5
5
|
workedForMs?: number;
|
|
6
|
+
yolo?: boolean;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export function Footer(props: FooterProps) {
|
|
10
|
+
const suffix = props.yolo ? " · yolo" : "";
|
|
9
11
|
if (props.status === "done") {
|
|
10
12
|
if (props.workedForMs === undefined) {
|
|
11
13
|
throw new Error("Done footer requires workedForMs");
|
|
@@ -14,27 +16,28 @@ export function Footer(props: FooterProps) {
|
|
|
14
16
|
return (
|
|
15
17
|
<StatusMessage variant="success">
|
|
16
18
|
Worked for {formatDuration(props.workedForMs)}
|
|
19
|
+
{suffix}
|
|
17
20
|
</StatusMessage>
|
|
18
21
|
);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
if (props.status === "failed") {
|
|
22
|
-
return <StatusMessage variant="error">failed</StatusMessage>;
|
|
25
|
+
return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
if (props.status === "running") {
|
|
26
|
-
return <Spinner label=
|
|
29
|
+
return <Spinner label={`Running${suffix}`} />;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (props.status === "cancelling") {
|
|
30
|
-
return <StatusMessage variant="info">cancelling</StatusMessage>;
|
|
33
|
+
return <StatusMessage variant="info">cancelling{suffix}</StatusMessage>;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
if (props.status === "cancelled") {
|
|
34
|
-
return <StatusMessage variant="info">cancelled</StatusMessage>;
|
|
37
|
+
return <StatusMessage variant="info">cancelled{suffix}</StatusMessage>;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
return <StatusMessage variant="info">idle</StatusMessage>;
|
|
40
|
+
return <StatusMessage variant="info">idle{suffix}</StatusMessage>;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function formatDuration(durationMs: number): string {
|
|
@@ -9,7 +9,10 @@ import { ImageNotRecognizedError } from "../../image/image-probe";
|
|
|
9
9
|
import type { ImportedImageAsset } from "../../image/image-asset-store";
|
|
10
10
|
import type { ImageAssetRef } from "../../image/image-types";
|
|
11
11
|
import { runtimeIdFactory, type RuntimeIdFactory } from "../../ids/runtime-id";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
formatContextUsageLine,
|
|
14
|
+
formatLatestProviderCacheRate,
|
|
15
|
+
} from "../context-format";
|
|
13
16
|
import {
|
|
14
17
|
type FileMentionMatch,
|
|
15
18
|
findFileMention,
|
|
@@ -722,6 +725,9 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
722
725
|
const showFileSuggestions = filePopupActive;
|
|
723
726
|
const showSlashSuggestions = suggestions.length > 0 && !locked;
|
|
724
727
|
const showSuggestions = showFileSuggestions || showSlashSuggestions;
|
|
728
|
+
const cacheRate = formatLatestProviderCacheRate(
|
|
729
|
+
props.contextUsage?.lastProviderUsage,
|
|
730
|
+
);
|
|
725
731
|
return (
|
|
726
732
|
<Box flexDirection="column">
|
|
727
733
|
<Box width="100%" borderStyle="single" borderLeft={false} borderRight={false}>
|
|
@@ -745,6 +751,12 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
745
751
|
</Text>
|
|
746
752
|
</>
|
|
747
753
|
)}
|
|
754
|
+
{cacheRate === undefined ? null : (
|
|
755
|
+
<>
|
|
756
|
+
<Text dimColor> · </Text>
|
|
757
|
+
<Text dimColor>{cacheRate}</Text>
|
|
758
|
+
</>
|
|
759
|
+
)}
|
|
748
760
|
</Box>
|
|
749
761
|
)}
|
|
750
762
|
{showFileSuggestions ? (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { Fragment } from "react";
|
|
3
3
|
import type { TimelineItem } from "../event-store";
|
|
4
|
+
import type { AssistantStreamSectionItem } from "../tui-projection-store";
|
|
4
5
|
import { AssistantMarkdown } from "./assistant-markdown";
|
|
5
6
|
import { BashResultView } from "./bash-result-view";
|
|
6
7
|
import { DiffView } from "./diff-view";
|
|
@@ -54,6 +55,15 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
54
55
|
);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
|
|
59
|
+
return (
|
|
60
|
+
<Fragment>
|
|
61
|
+
{props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
|
|
62
|
+
<AssistantMarkdown text={props.item.markdown} />
|
|
63
|
+
</Fragment>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
function renderUserPrompt(prompt: NonNullable<TimelineItem["userPrompt"]>) {
|
|
58
68
|
const chars = [...prompt.text];
|
|
59
69
|
const fragments: React.ReactNode[] = [];
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { ContextUsageSnapshot } from "../agent/context-meter";
|
|
2
|
+
import type { ModelUsage } from "../model/model-client";
|
|
3
|
+
|
|
4
|
+
export function formatLatestProviderCacheRate(
|
|
5
|
+
usage: ModelUsage | undefined,
|
|
6
|
+
): string | undefined {
|
|
7
|
+
const hit = usage?.promptCacheHitTokens;
|
|
8
|
+
const miss = usage?.promptCacheMissTokens;
|
|
9
|
+
if (hit === undefined || miss === undefined || hit + miss === 0) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
// Floor instead of round: an append turn is never a true 100% hit, and
|
|
13
|
+
// rounding would display 99.5%+ as a misleading "cache 100%". A genuine
|
|
14
|
+
// full hit (miss === 0, e.g. an identical resent request) still floors to
|
|
15
|
+
// exactly 100. The min() guards against float rounding when miss > 0.
|
|
16
|
+
const percent = Math.min(99, Math.floor((hit / (hit + miss)) * 100));
|
|
17
|
+
return `cache ${miss === 0 ? 100 : percent}%`;
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
export function formatContextUsageLine(
|
|
4
21
|
usage: Pick<
|
package/src/tui/event-store.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
bashResultDetail,
|
|
4
4
|
type BashDisplayDetail,
|
|
5
5
|
} from "../events/bash-result-detail";
|
|
6
|
-
import type { AgentEvent } from "../events/types";
|
|
6
|
+
import type { AgentEvent, ModelRequestFailedData } from "../events/types";
|
|
7
7
|
import type { ContextUsageSnapshot } from "../agent/context-meter";
|
|
8
8
|
import type {
|
|
9
9
|
ModelContextBudget,
|
|
@@ -162,12 +162,21 @@ export function reduceTuiProjection(
|
|
|
162
162
|
})
|
|
163
163
|
: updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
164
164
|
...item,
|
|
165
|
-
text: `model iteration ${event.iterationNumber} · retrying`,
|
|
165
|
+
text: `model iteration ${event.iterationNumber} · retrying (attempt ${event.data.attemptNumber}/${event.data.maxAttempts})`,
|
|
166
166
|
status: "running",
|
|
167
167
|
})),
|
|
168
168
|
);
|
|
169
169
|
case "model.request.failed":
|
|
170
|
-
|
|
170
|
+
if (event.data.retryDisposition !== "scheduled") {
|
|
171
|
+
return state;
|
|
172
|
+
}
|
|
173
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
174
|
+
updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
175
|
+
...item,
|
|
176
|
+
text: `model iteration ${requireEventNumber(event.iterationNumber, "model.request.failed iterationNumber")} · ${modelRequestRetryText(event.data)}`,
|
|
177
|
+
status: "running",
|
|
178
|
+
})),
|
|
179
|
+
);
|
|
171
180
|
case "model.request.finished":
|
|
172
181
|
return updateActiveTurn(state, event, policy, (turn) =>
|
|
173
182
|
updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
@@ -214,6 +223,37 @@ export function reduceTuiProjection(
|
|
|
214
223
|
status: event.data.ok ? "ok" : "failed",
|
|
215
224
|
})),
|
|
216
225
|
);
|
|
226
|
+
case "tool.confirmation.requested":
|
|
227
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
228
|
+
updateTurnItem(
|
|
229
|
+
turn,
|
|
230
|
+
toolCallRef(
|
|
231
|
+
requireEventString(
|
|
232
|
+
event.toolCallId,
|
|
233
|
+
"tool.confirmation.requested toolCallId",
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
(item) => ({
|
|
237
|
+
...item,
|
|
238
|
+
text: `Bash confirmation requested · ${event.data.reason}`,
|
|
239
|
+
status: "running",
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
case "tool.confirmation.resolved":
|
|
244
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
245
|
+
appendTurnItem(turn, {
|
|
246
|
+
id: `confirmation-${event.toolCallId}-${event.eventSequence}`,
|
|
247
|
+
label: "bash guard",
|
|
248
|
+
text: `${event.data.decision} · ${event.data.reason}`,
|
|
249
|
+
status:
|
|
250
|
+
event.data.decision === "allow"
|
|
251
|
+
? "ok"
|
|
252
|
+
: event.data.decision === "cancelled"
|
|
253
|
+
? "cancelled"
|
|
254
|
+
: "failed",
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
217
257
|
case "bash.task.backgrounded":
|
|
218
258
|
case "bash.task.stopping":
|
|
219
259
|
case "bash.task.finished":
|
|
@@ -679,6 +719,21 @@ export function completedModelRequestText(
|
|
|
679
719
|
return `model iteration ${iterationNumber} -> assistant response`;
|
|
680
720
|
}
|
|
681
721
|
|
|
722
|
+
function modelRequestRetryText(data: ModelRequestFailedData): string {
|
|
723
|
+
const attempt = `attempt ${data.attemptNumber + 1}/${data.maxAttempts}`;
|
|
724
|
+
if (data.retryDelayMs !== undefined && data.retryDelayMs > 0) {
|
|
725
|
+
const seconds = Math.max(1, Math.round(data.retryDelayMs / 1000));
|
|
726
|
+
const reason =
|
|
727
|
+
data.code === "provider_rate_limited"
|
|
728
|
+
? "rate limited"
|
|
729
|
+
: data.code === "provider_unavailable"
|
|
730
|
+
? "provider unavailable"
|
|
731
|
+
: data.code;
|
|
732
|
+
return `${reason} · retrying in ${seconds}s (${attempt})`;
|
|
733
|
+
}
|
|
734
|
+
return `retrying (${attempt})`;
|
|
735
|
+
}
|
|
736
|
+
|
|
682
737
|
export function toolCallStartedProjection(input: {
|
|
683
738
|
name: string;
|
|
684
739
|
args: unknown;
|
|
@@ -774,6 +829,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
774
829
|
? base
|
|
775
830
|
: `${base} -> ${raw.bytesWritten} bytes`;
|
|
776
831
|
}
|
|
832
|
+
case "delete":
|
|
833
|
+
return `${base} -> deleted`;
|
|
777
834
|
case "web_search":
|
|
778
835
|
return raw.resultCount === undefined
|
|
779
836
|
? base
|
|
@@ -857,6 +914,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
|
|
|
857
914
|
case "read":
|
|
858
915
|
case "write":
|
|
859
916
|
case "edit":
|
|
917
|
+
case "delete":
|
|
860
918
|
case "glob":
|
|
861
919
|
case "grep":
|
|
862
920
|
case "task_list":
|
|
@@ -890,6 +948,7 @@ function toolRawResultDiff(
|
|
|
890
948
|
diffTruncated: raw.patchTruncated === true,
|
|
891
949
|
};
|
|
892
950
|
case "read":
|
|
951
|
+
case "delete":
|
|
893
952
|
case "glob":
|
|
894
953
|
case "grep":
|
|
895
954
|
case "bash":
|
|
@@ -24,6 +24,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
|
|
24
24
|
usage: "/mcp",
|
|
25
25
|
description: "Show MCP servers and runtime tools",
|
|
26
26
|
},
|
|
27
|
+
{
|
|
28
|
+
name: "yolo",
|
|
29
|
+
usage: "/yolo [on|off]",
|
|
30
|
+
description: "Show or change destructive Bash confirmation",
|
|
31
|
+
},
|
|
27
32
|
{
|
|
28
33
|
name: "memory",
|
|
29
34
|
usage: "/memory",
|
|
@@ -34,6 +39,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
|
|
34
39
|
usage: "/compact [retire]",
|
|
35
40
|
description: "Swap tool output or retire a cold history prefix",
|
|
36
41
|
},
|
|
42
|
+
{
|
|
43
|
+
name: "undo",
|
|
44
|
+
usage: "/undo",
|
|
45
|
+
description: "Undo the latest Write/Edit/Delete turn",
|
|
46
|
+
},
|
|
37
47
|
{
|
|
38
48
|
name: "clear",
|
|
39
49
|
usage: "/clear",
|
|
@@ -70,11 +80,14 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
|
|
70
80
|
|
|
71
81
|
export type ParsedSlashCommand =
|
|
72
82
|
| { type: "status" }
|
|
83
|
+
| { type: "yolo_status" }
|
|
84
|
+
| { type: "yolo"; enabled: boolean }
|
|
73
85
|
| { type: "skills" }
|
|
74
86
|
| { type: "mcp" }
|
|
75
87
|
| { type: "memory" }
|
|
76
88
|
| { type: "compact" }
|
|
77
89
|
| { type: "compact_retire" }
|
|
90
|
+
| { type: "undo" }
|
|
78
91
|
| { type: "clear" }
|
|
79
92
|
| { type: "fork" }
|
|
80
93
|
| { type: "view"; filePath: string }
|
|
@@ -111,6 +124,15 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
111
124
|
if (command === "/status" && tokens.length === 1) {
|
|
112
125
|
return { type: "status" };
|
|
113
126
|
}
|
|
127
|
+
if (command === "/yolo") {
|
|
128
|
+
if (tokens.length === 1) {
|
|
129
|
+
return { type: "yolo_status" };
|
|
130
|
+
}
|
|
131
|
+
if (tokens.length === 2 && (tokens[1] === "on" || tokens[1] === "off")) {
|
|
132
|
+
return { type: "yolo", enabled: tokens[1] === "on" };
|
|
133
|
+
}
|
|
134
|
+
throw slashCommandUsageError("yolo");
|
|
135
|
+
}
|
|
114
136
|
if (command === "/skills" && tokens.length === 1) {
|
|
115
137
|
return { type: "skills" };
|
|
116
138
|
}
|
|
@@ -135,6 +157,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
135
157
|
}
|
|
136
158
|
throw slashCommandUsageError("compact");
|
|
137
159
|
}
|
|
160
|
+
if (command === "/undo") {
|
|
161
|
+
if (tokens.length === 1) {
|
|
162
|
+
return { type: "undo" };
|
|
163
|
+
}
|
|
164
|
+
throw slashCommandUsageError("undo");
|
|
165
|
+
}
|
|
138
166
|
if (command === "/clear") {
|
|
139
167
|
if (tokens.length === 1) {
|
|
140
168
|
return { type: "clear" };
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { EventSink } from "../events/event-sink";
|
|
2
2
|
import type { AgentEvent } from "../events/types";
|
|
3
|
+
import type {
|
|
4
|
+
AssistantTextDeltaSink,
|
|
5
|
+
AssistantTextDeltaUpdate,
|
|
6
|
+
} from "../agent/assistant-text-delta";
|
|
3
7
|
import {
|
|
4
8
|
createInitialTuiProjectionState,
|
|
5
9
|
firstRunningIndex,
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
type TuiProjectionPolicy,
|
|
15
19
|
validateTuiProjectionPolicy,
|
|
16
20
|
} from "./tui-projection-policy";
|
|
21
|
+
import { MarkdownSectionFramer } from "./assistant-markdown-section-framer";
|
|
17
22
|
|
|
18
23
|
export type TuiProjectionStoreInput = {
|
|
19
24
|
sessionId: string;
|
|
@@ -23,18 +28,49 @@ export type TuiProjectionStoreInput = {
|
|
|
23
28
|
initialSnapshot?: TuiProjectionState;
|
|
24
29
|
};
|
|
25
30
|
|
|
31
|
+
export type AssistantStreamSectionItem = Readonly<{
|
|
32
|
+
kind: "assistant-stream-section";
|
|
33
|
+
id: string;
|
|
34
|
+
iterationId: string;
|
|
35
|
+
attemptNumber: number;
|
|
36
|
+
sectionNumber: number;
|
|
37
|
+
markdown: string;
|
|
38
|
+
showAssistantLabel: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
export type TuiCommittedItem = TimelineItem | AssistantStreamSectionItem;
|
|
42
|
+
|
|
43
|
+
export function isAssistantStreamSectionItem(
|
|
44
|
+
item: TuiCommittedItem,
|
|
45
|
+
): item is AssistantStreamSectionItem {
|
|
46
|
+
return "kind" in item && item.kind === "assistant-stream-section";
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
export type TuiTimelineLog = Readonly<{
|
|
27
|
-
committed: readonly
|
|
50
|
+
committed: readonly TuiCommittedItem[];
|
|
28
51
|
live: readonly TimelineItem[];
|
|
29
52
|
}>;
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
type AssistantStreamAttempt = {
|
|
55
|
+
readonly sessionId: string;
|
|
56
|
+
readonly turnId: string;
|
|
57
|
+
readonly turnNumber: number;
|
|
58
|
+
readonly iterationId: string;
|
|
59
|
+
readonly iterationNumber: number;
|
|
60
|
+
readonly attemptNumber: number;
|
|
61
|
+
readonly framer: MarkdownSectionFramer;
|
|
62
|
+
sectionCount: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
|
|
32
66
|
readonly name = "tui-projection-store";
|
|
33
67
|
private readonly listeners = new Set<() => void>();
|
|
34
68
|
private readonly policy: TuiProjectionPolicy;
|
|
35
69
|
private readonly printed = new Set<string>();
|
|
70
|
+
private readonly physicallyAdoptedIterations = new Map<string, string>();
|
|
36
71
|
private snapshot: TuiProjectionState;
|
|
37
72
|
private log: TuiTimelineLog = { committed: [], live: [] };
|
|
73
|
+
private assistantStreamAttempt?: AssistantStreamAttempt;
|
|
38
74
|
|
|
39
75
|
constructor(input: TuiProjectionStoreInput) {
|
|
40
76
|
this.policy = validateTuiProjectionPolicy(
|
|
@@ -62,15 +98,40 @@ export class TuiProjectionStore implements EventSink {
|
|
|
62
98
|
|
|
63
99
|
async append(event: AgentEvent): Promise<void> {
|
|
64
100
|
const next = reduceTuiProjection(this.snapshot, event, this.policy);
|
|
65
|
-
|
|
101
|
+
const snapshotChanged = next !== this.snapshot;
|
|
102
|
+
this.snapshot = next;
|
|
103
|
+
const presentationChanged = this.applyAssistantStreamEvent(event);
|
|
104
|
+
if (!snapshotChanged && !presentationChanged) {
|
|
66
105
|
return;
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
if (snapshotChanged) {
|
|
109
|
+
this.refreshLog();
|
|
110
|
+
}
|
|
111
|
+
this.notifyListeners();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
|
|
115
|
+
const attempt = this.assistantStreamAttempt;
|
|
116
|
+
if (
|
|
117
|
+
attempt === undefined ||
|
|
118
|
+
update.content === "" ||
|
|
119
|
+
!sameAssistantStreamAttempt(attempt, update)
|
|
120
|
+
) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const frames = attempt.framer.push(update.content);
|
|
125
|
+
if (frames.length === 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const committed = [...this.log.committed];
|
|
129
|
+
for (const frame of frames) {
|
|
130
|
+
attempt.sectionCount += 1;
|
|
131
|
+
committed.push(this.sectionItem(attempt, frame.markdown));
|
|
73
132
|
}
|
|
133
|
+
this.log = { committed, live: this.log.live };
|
|
134
|
+
this.notifyListeners();
|
|
74
135
|
}
|
|
75
136
|
|
|
76
137
|
hydrate(snapshot: TuiProjectionState): void {
|
|
@@ -91,6 +152,130 @@ export class TuiProjectionStore implements EventSink {
|
|
|
91
152
|
this.policy,
|
|
92
153
|
);
|
|
93
154
|
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
155
|
+
this.notifyListeners();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private applyAssistantStreamEvent(event: AgentEvent): boolean {
|
|
159
|
+
switch (event.type) {
|
|
160
|
+
case "model.request.started":
|
|
161
|
+
this.assistantStreamAttempt = assistantStreamAttempt(event);
|
|
162
|
+
return false;
|
|
163
|
+
case "model.request.failed":
|
|
164
|
+
return this.failAssistantStreamAttempt(event);
|
|
165
|
+
case "model.request.finished":
|
|
166
|
+
return this.finishAssistantStreamAttempt(event);
|
|
167
|
+
case "assistant.progress":
|
|
168
|
+
if (
|
|
169
|
+
event.iterationId !== undefined &&
|
|
170
|
+
this.physicallyAdoptedIterations.has(event.iterationId)
|
|
171
|
+
) {
|
|
172
|
+
this.printed.add(`assistant-${event.iterationId}-${event.eventSequence}`);
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
case "turn.finished": {
|
|
176
|
+
const adopted = this.physicallyAdoptedIterations.has(
|
|
177
|
+
event.data.lastIteration.iterationId,
|
|
178
|
+
);
|
|
179
|
+
if (adopted) {
|
|
180
|
+
this.printed.add(`turn-${event.turnId}-final-${event.eventSequence}`);
|
|
181
|
+
}
|
|
182
|
+
this.clearAdoptedTurn(event.turnId);
|
|
183
|
+
this.assistantStreamAttempt = undefined;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
case "turn.failed":
|
|
187
|
+
case "turn.cancelled":
|
|
188
|
+
this.clearAdoptedTurn(event.turnId);
|
|
189
|
+
this.assistantStreamAttempt = undefined;
|
|
190
|
+
return false;
|
|
191
|
+
case "session.finished":
|
|
192
|
+
this.physicallyAdoptedIterations.clear();
|
|
193
|
+
this.assistantStreamAttempt = undefined;
|
|
194
|
+
return false;
|
|
195
|
+
default:
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private failAssistantStreamAttempt(
|
|
201
|
+
event: Extract<AgentEvent, { type: "model.request.failed" }>,
|
|
202
|
+
): boolean {
|
|
203
|
+
const attempt = this.assistantStreamAttempt;
|
|
204
|
+
if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
this.assistantStreamAttempt = undefined;
|
|
208
|
+
if (event.data.retryDisposition !== "scheduled" || attempt.sectionCount === 0) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
this.appendCommitted({
|
|
212
|
+
id: `assistant-stream-retry-${attempt.iterationId}-${attempt.attemptNumber}`,
|
|
213
|
+
text: "assistant response interrupted · retrying",
|
|
214
|
+
status: "info",
|
|
215
|
+
});
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private finishAssistantStreamAttempt(
|
|
220
|
+
event: Extract<AgentEvent, { type: "model.request.finished" }>,
|
|
221
|
+
): boolean {
|
|
222
|
+
const attempt = this.assistantStreamAttempt;
|
|
223
|
+
if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
this.assistantStreamAttempt = undefined;
|
|
227
|
+
const result = attempt.framer.finish();
|
|
228
|
+
if (
|
|
229
|
+
attempt.sectionCount === 0 ||
|
|
230
|
+
result.content !== (event.data.output.message.content ?? "")
|
|
231
|
+
) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.printed.add(`model-${attempt.iterationId}`);
|
|
236
|
+
this.physicallyAdoptedIterations.set(attempt.iterationId, attempt.turnId);
|
|
237
|
+
if (result.tail === "") {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
attempt.sectionCount += 1;
|
|
241
|
+
this.appendCommitted(this.sectionItem(attempt, result.tail));
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private sectionItem(
|
|
246
|
+
attempt: AssistantStreamAttempt,
|
|
247
|
+
markdown: string,
|
|
248
|
+
): AssistantStreamSectionItem {
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
kind: "assistant-stream-section",
|
|
251
|
+
id: `assistant-stream-${attempt.iterationId}-${attempt.attemptNumber}-${attempt.sectionCount}`,
|
|
252
|
+
iterationId: attempt.iterationId,
|
|
253
|
+
attemptNumber: attempt.attemptNumber,
|
|
254
|
+
sectionNumber: attempt.sectionCount,
|
|
255
|
+
markdown,
|
|
256
|
+
showAssistantLabel: attempt.sectionCount === 1,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private appendCommitted(item: TuiCommittedItem): void {
|
|
261
|
+
this.log = {
|
|
262
|
+
committed: [...this.log.committed, item],
|
|
263
|
+
live: this.log.live,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private clearAdoptedTurn(turnId: string | undefined): void {
|
|
268
|
+
if (turnId === undefined) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const [iterationId, adoptedTurnId] of this.physicallyAdoptedIterations) {
|
|
272
|
+
if (adoptedTurnId === turnId) {
|
|
273
|
+
this.physicallyAdoptedIterations.delete(iterationId);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private notifyListeners(): void {
|
|
94
279
|
for (const listener of this.listeners) {
|
|
95
280
|
listener();
|
|
96
281
|
}
|
|
@@ -112,6 +297,60 @@ export class TuiProjectionStore implements EventSink {
|
|
|
112
297
|
}
|
|
113
298
|
}
|
|
114
299
|
|
|
300
|
+
function assistantStreamAttempt(
|
|
301
|
+
event: Extract<AgentEvent, { type: "model.request.started" }>,
|
|
302
|
+
): AssistantStreamAttempt | undefined {
|
|
303
|
+
if (
|
|
304
|
+
event.turnId === undefined ||
|
|
305
|
+
event.turnNumber === undefined ||
|
|
306
|
+
event.iterationId === undefined ||
|
|
307
|
+
event.iterationNumber === undefined
|
|
308
|
+
) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
sessionId: event.sessionId,
|
|
313
|
+
turnId: event.turnId,
|
|
314
|
+
turnNumber: event.turnNumber,
|
|
315
|
+
iterationId: event.iterationId,
|
|
316
|
+
iterationNumber: event.iterationNumber,
|
|
317
|
+
attemptNumber: event.data.attemptNumber,
|
|
318
|
+
framer: new MarkdownSectionFramer(),
|
|
319
|
+
sectionCount: 0,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function sameAssistantStreamAttempt(
|
|
324
|
+
attempt: AssistantStreamAttempt,
|
|
325
|
+
update: AssistantTextDeltaUpdate,
|
|
326
|
+
): boolean {
|
|
327
|
+
return (
|
|
328
|
+
attempt.sessionId === update.sessionId &&
|
|
329
|
+
attempt.turnId === update.turnId &&
|
|
330
|
+
attempt.turnNumber === update.turnNumber &&
|
|
331
|
+
attempt.iterationId === update.iterationId &&
|
|
332
|
+
attempt.iterationNumber === update.iterationNumber &&
|
|
333
|
+
attempt.attemptNumber === update.attemptNumber
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function sameAssistantStreamEvent(
|
|
338
|
+
attempt: AssistantStreamAttempt,
|
|
339
|
+
event: Extract<
|
|
340
|
+
AgentEvent,
|
|
341
|
+
{ type: "model.request.failed" | "model.request.finished" }
|
|
342
|
+
>,
|
|
343
|
+
): boolean {
|
|
344
|
+
return (
|
|
345
|
+
attempt.sessionId === event.sessionId &&
|
|
346
|
+
attempt.turnId === event.turnId &&
|
|
347
|
+
attempt.turnNumber === event.turnNumber &&
|
|
348
|
+
attempt.iterationId === event.iterationId &&
|
|
349
|
+
attempt.iterationNumber === event.iterationNumber &&
|
|
350
|
+
attempt.attemptNumber === event.data.attemptNumber
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
115
354
|
function validateInitialSnapshot(
|
|
116
355
|
input: Pick<TuiProjectionStoreInput, "sessionId" | "modelName" | "workspaceRoot">,
|
|
117
356
|
snapshot: TuiProjectionState,
|
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
import type {
|
|
6
6
|
ExecuteTurnInput,
|
|
7
7
|
AcceptedTurn,
|
|
8
|
+
BashGuardSnapshot,
|
|
8
9
|
RuntimeSession,
|
|
9
10
|
RuntimeSkillsSnapshot,
|
|
10
11
|
SessionDisposeReason,
|
|
11
12
|
} from "../agent/runtime-session";
|
|
12
13
|
import type { RunAgentResult, UserMessage } from "../agent/types";
|
|
14
|
+
import type { TurnUndoResult } from "../tools/turn-undo-manager";
|
|
13
15
|
import type { ImageAssetRef } from "../image/image-types";
|
|
14
16
|
import type { ImportedImageAsset } from "../image/image-asset-store";
|
|
15
17
|
import type { SessionId } from "../ids/runtime-id";
|
|
@@ -39,6 +41,10 @@ export type TuiSessionBinding = {
|
|
|
39
41
|
) => Promise<void>;
|
|
40
42
|
admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
|
|
41
43
|
executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
|
|
44
|
+
bashGuard(): BashGuardSnapshot;
|
|
45
|
+
subscribeBashGuard(listener: () => void): () => void;
|
|
46
|
+
setYoloMode(enabled: boolean): void;
|
|
47
|
+
resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
|
|
42
48
|
};
|
|
43
49
|
|
|
44
50
|
export type TuiSessionController = {
|
|
@@ -47,6 +53,7 @@ export type TuiSessionController = {
|
|
|
47
53
|
listSessions: () => Promise<readonly SessionSummary[]>;
|
|
48
54
|
compact: () => Promise<ContextCompactionResult>;
|
|
49
55
|
retire: () => Promise<ContextRetirementResult>;
|
|
56
|
+
undo: () => Promise<TurnUndoResult>;
|
|
50
57
|
fork: (beforeCommit?: () => void) => Promise<SessionId>;
|
|
51
58
|
clear: (beforeCommit?: () => void) => Promise<void>;
|
|
52
59
|
resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
|
|
@@ -100,6 +107,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
100
107
|
return this.serialize(() => this.binding.runtimeSession.retireContext());
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
undo(): Promise<TurnUndoResult> {
|
|
111
|
+
return this.serialize(() =>
|
|
112
|
+
this.binding.runtimeSession.undoLatestFileMutationTurn(),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
103
116
|
fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
104
117
|
return this.serialize(async () => {
|
|
105
118
|
const targetSessionId = createUuidV7() as SessionId;
|
|
@@ -230,6 +243,11 @@ export function managedTuiBinding(input: {
|
|
|
230
243
|
userMessage,
|
|
231
244
|
signal,
|
|
232
245
|
} satisfies ExecuteTurnInput),
|
|
246
|
+
bashGuard: () => input.runtimeSession.bashGuard(),
|
|
247
|
+
subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
|
|
248
|
+
setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
|
|
249
|
+
resolveBashConfirmation: (decision) =>
|
|
250
|
+
input.runtimeSession.resolveBashConfirmation(decision),
|
|
233
251
|
};
|
|
234
252
|
}
|
|
235
253
|
|