tinker-agent 1.5.0 → 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 +48 -1
- package/README.md +13 -5
- package/package.json +7 -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 +21 -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 +190 -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 +301 -134
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +9 -12
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +19 -11
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +75 -3
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +277 -5
- package/src/tui/tui-session-controller.ts +32 -8
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
2
|
+
|
|
3
|
+
export type TuiShikiHighlighter = (code: string, language?: string) => string;
|
|
4
|
+
|
|
5
|
+
type ShikiToken = {
|
|
6
|
+
readonly content: string;
|
|
7
|
+
readonly color?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type TuiShikiTokenizer = {
|
|
11
|
+
codeToTokensBase(
|
|
12
|
+
code: string,
|
|
13
|
+
options: { readonly lang: string; readonly theme: string },
|
|
14
|
+
): readonly (readonly ShikiToken[])[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const THEME = "github-dark";
|
|
18
|
+
const HIGHLIGHTED_LANGUAGES = [
|
|
19
|
+
"typescript",
|
|
20
|
+
"javascript",
|
|
21
|
+
"tsx",
|
|
22
|
+
"jsx",
|
|
23
|
+
"json",
|
|
24
|
+
"bash",
|
|
25
|
+
"shellscript",
|
|
26
|
+
"python",
|
|
27
|
+
"markdown",
|
|
28
|
+
"html",
|
|
29
|
+
"css",
|
|
30
|
+
"yaml",
|
|
31
|
+
"diff",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
let preparation: Promise<void> | undefined;
|
|
35
|
+
let highlighter: TuiShikiHighlighter | undefined;
|
|
36
|
+
|
|
37
|
+
export function prepareShikiHighlighter(): Promise<void> {
|
|
38
|
+
preparation ??= createTuiShikiHighlighter(async () => {
|
|
39
|
+
const { createHighlighter } = await import("shiki");
|
|
40
|
+
const tokenizer = await createHighlighter({
|
|
41
|
+
themes: [THEME],
|
|
42
|
+
langs: [...HIGHLIGHTED_LANGUAGES],
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
codeToTokensBase: (code, options) =>
|
|
46
|
+
tokenizer.codeToTokensBase(code, {
|
|
47
|
+
lang: options.lang as BundledLanguage,
|
|
48
|
+
theme: options.theme as BundledTheme,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}).then((prepared) => {
|
|
52
|
+
highlighter = prepared;
|
|
53
|
+
});
|
|
54
|
+
return preparation;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getPreparedShikiHighlighter(): TuiShikiHighlighter | undefined {
|
|
58
|
+
return highlighter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createTuiShikiHighlighter(
|
|
62
|
+
createTokenizer: () => Promise<TuiShikiTokenizer>,
|
|
63
|
+
): Promise<TuiShikiHighlighter | undefined> {
|
|
64
|
+
try {
|
|
65
|
+
const tokenizer = await createTokenizer();
|
|
66
|
+
return (code, language) => {
|
|
67
|
+
if (language === undefined || language === "") {
|
|
68
|
+
return code;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return tokenizer
|
|
72
|
+
.codeToTokensBase(code, { lang: language, theme: THEME })
|
|
73
|
+
.map((line) =>
|
|
74
|
+
line
|
|
75
|
+
.map((token) => {
|
|
76
|
+
const ansi = tokenColorToAnsi(token.color);
|
|
77
|
+
return ansi === undefined
|
|
78
|
+
? token.content
|
|
79
|
+
: `${ansi}${token.content}\u001b[39m`;
|
|
80
|
+
})
|
|
81
|
+
.join(""),
|
|
82
|
+
)
|
|
83
|
+
.join("\n");
|
|
84
|
+
} catch {
|
|
85
|
+
return code;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokenColorToAnsi(color: string | undefined): string | undefined {
|
|
94
|
+
if (color === undefined || !color.startsWith("#") || color.length < 7) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const red = Number.parseInt(color.slice(1, 3), 16);
|
|
98
|
+
const green = Number.parseInt(color.slice(3, 5), 16);
|
|
99
|
+
const blue = Number.parseInt(color.slice(5, 7), 16);
|
|
100
|
+
if ([red, green, blue].some((component) => Number.isNaN(component))) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return `\u001b[38;2;${red};${green};${blue}m`;
|
|
104
|
+
}
|
|
@@ -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,8 +1,16 @@
|
|
|
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,
|
|
9
|
+
firstRunningIndex,
|
|
5
10
|
reduceTuiProjection,
|
|
11
|
+
timelineStreamItems,
|
|
12
|
+
visibleTimelineItems,
|
|
13
|
+
type TimelineItem,
|
|
6
14
|
type TuiProjectionState,
|
|
7
15
|
} from "./event-store";
|
|
8
16
|
import {
|
|
@@ -10,6 +18,7 @@ import {
|
|
|
10
18
|
type TuiProjectionPolicy,
|
|
11
19
|
validateTuiProjectionPolicy,
|
|
12
20
|
} from "./tui-projection-policy";
|
|
21
|
+
import { MarkdownSectionFramer } from "./assistant-markdown-section-framer";
|
|
13
22
|
|
|
14
23
|
export type TuiProjectionStoreInput = {
|
|
15
24
|
sessionId: string;
|
|
@@ -19,11 +28,49 @@ export type TuiProjectionStoreInput = {
|
|
|
19
28
|
initialSnapshot?: TuiProjectionState;
|
|
20
29
|
};
|
|
21
30
|
|
|
22
|
-
export
|
|
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
|
+
|
|
49
|
+
export type TuiTimelineLog = Readonly<{
|
|
50
|
+
committed: readonly TuiCommittedItem[];
|
|
51
|
+
live: readonly TimelineItem[];
|
|
52
|
+
}>;
|
|
53
|
+
|
|
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 {
|
|
23
66
|
readonly name = "tui-projection-store";
|
|
24
67
|
private readonly listeners = new Set<() => void>();
|
|
25
68
|
private readonly policy: TuiProjectionPolicy;
|
|
69
|
+
private readonly printed = new Set<string>();
|
|
70
|
+
private readonly physicallyAdoptedIterations = new Map<string, string>();
|
|
26
71
|
private snapshot: TuiProjectionState;
|
|
72
|
+
private log: TuiTimelineLog = { committed: [], live: [] };
|
|
73
|
+
private assistantStreamAttempt?: AssistantStreamAttempt;
|
|
27
74
|
|
|
28
75
|
constructor(input: TuiProjectionStoreInput) {
|
|
29
76
|
this.policy = validateTuiProjectionPolicy(
|
|
@@ -33,10 +80,15 @@ export class TuiProjectionStore implements EventSink {
|
|
|
33
80
|
input.initialSnapshot === undefined
|
|
34
81
|
? createInitialTuiProjectionState(input)
|
|
35
82
|
: validateInitialSnapshot(input, input.initialSnapshot, this.policy);
|
|
83
|
+
if (input.initialSnapshot !== undefined) {
|
|
84
|
+
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
readonly getSnapshot = (): TuiProjectionState => this.snapshot;
|
|
39
89
|
|
|
90
|
+
readonly getLogSnapshot = (): TuiTimelineLog => this.log;
|
|
91
|
+
|
|
40
92
|
readonly subscribe = (listener: () => void): (() => void) => {
|
|
41
93
|
this.listeners.add(listener);
|
|
42
94
|
return () => {
|
|
@@ -46,14 +98,40 @@ export class TuiProjectionStore implements EventSink {
|
|
|
46
98
|
|
|
47
99
|
async append(event: AgentEvent): Promise<void> {
|
|
48
100
|
const next = reduceTuiProjection(this.snapshot, event, this.policy);
|
|
49
|
-
|
|
101
|
+
const snapshotChanged = next !== this.snapshot;
|
|
102
|
+
this.snapshot = next;
|
|
103
|
+
const presentationChanged = this.applyAssistantStreamEvent(event);
|
|
104
|
+
if (!snapshotChanged && !presentationChanged) {
|
|
50
105
|
return;
|
|
51
106
|
}
|
|
52
107
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
56
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));
|
|
132
|
+
}
|
|
133
|
+
this.log = { committed, live: this.log.live };
|
|
134
|
+
this.notifyListeners();
|
|
57
135
|
}
|
|
58
136
|
|
|
59
137
|
hydrate(snapshot: TuiProjectionState): void {
|
|
@@ -73,10 +151,204 @@ export class TuiProjectionStore implements EventSink {
|
|
|
73
151
|
snapshot,
|
|
74
152
|
this.policy,
|
|
75
153
|
);
|
|
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 {
|
|
76
279
|
for (const listener of this.listeners) {
|
|
77
280
|
listener();
|
|
78
281
|
}
|
|
79
282
|
}
|
|
283
|
+
|
|
284
|
+
private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
|
|
285
|
+
const settledEnd = firstRunningIndex(stream);
|
|
286
|
+
const pending = stream
|
|
287
|
+
.slice(0, settledEnd)
|
|
288
|
+
.filter((item) => !this.printed.has(item.id));
|
|
289
|
+
for (const item of pending) {
|
|
290
|
+
this.printed.add(item.id);
|
|
291
|
+
}
|
|
292
|
+
this.log = {
|
|
293
|
+
committed:
|
|
294
|
+
pending.length === 0 ? this.log.committed : [...this.log.committed, ...pending],
|
|
295
|
+
live: stream.slice(settledEnd),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
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
|
+
);
|
|
80
352
|
}
|
|
81
353
|
|
|
82
354
|
function validateInitialSnapshot(
|
|
@@ -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,11 +53,12 @@ export type TuiSessionController = {
|
|
|
47
53
|
listSessions: () => Promise<readonly SessionSummary[]>;
|
|
48
54
|
compact: () => Promise<ContextCompactionResult>;
|
|
49
55
|
retire: () => Promise<ContextRetirementResult>;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
undo: () => Promise<TurnUndoResult>;
|
|
57
|
+
fork: (beforeCommit?: () => void) => Promise<SessionId>;
|
|
58
|
+
clear: (beforeCommit?: () => void) => Promise<void>;
|
|
59
|
+
resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
|
|
53
60
|
delete: (sessionId: SessionId) => Promise<void>;
|
|
54
|
-
switchModel: (profile: ModelProfile) => Promise<void>;
|
|
61
|
+
switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
export type ManagedTuiSessionBinding = TuiSessionBinding & {
|
|
@@ -100,7 +107,13 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
100
107
|
return this.serialize(() => this.binding.runtimeSession.retireContext());
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
|
|
110
|
+
undo(): Promise<TurnUndoResult> {
|
|
111
|
+
return this.serialize(() =>
|
|
112
|
+
this.binding.runtimeSession.undoLatestFileMutationTurn(),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
104
117
|
return this.serialize(async () => {
|
|
105
118
|
const targetSessionId = createUuidV7() as SessionId;
|
|
106
119
|
await this.replaceSession(
|
|
@@ -109,21 +122,23 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
109
122
|
await current.runtimeSession.cloneSession(targetSessionId);
|
|
110
123
|
return this.openSession(targetSessionId);
|
|
111
124
|
},
|
|
125
|
+
beforeCommit,
|
|
112
126
|
);
|
|
113
127
|
return targetSessionId;
|
|
114
128
|
});
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
clear(): Promise<void> {
|
|
131
|
+
clear(beforeCommit?: () => void): Promise<void> {
|
|
118
132
|
return this.serialize(() =>
|
|
119
133
|
this.replaceSession(
|
|
120
134
|
"Cannot clear the session while a turn, context operation, or background task is active.",
|
|
121
135
|
(current) => this.createFreshSession(current),
|
|
136
|
+
beforeCommit,
|
|
122
137
|
),
|
|
123
138
|
);
|
|
124
139
|
}
|
|
125
140
|
|
|
126
|
-
resume(sessionId: SessionId): Promise<void> {
|
|
141
|
+
resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
|
|
127
142
|
return this.serialize(async () => {
|
|
128
143
|
if (sessionId === this.binding.sessionId) {
|
|
129
144
|
throw new Error(`Session ${sessionId} is already current.`);
|
|
@@ -131,6 +146,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
131
146
|
await this.replaceSession(
|
|
132
147
|
"Cannot switch sessions while a turn or background task is active.",
|
|
133
148
|
() => this.openSession(sessionId),
|
|
149
|
+
beforeCommit,
|
|
134
150
|
);
|
|
135
151
|
});
|
|
136
152
|
}
|
|
@@ -139,11 +155,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
139
155
|
return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
switchModel(profile: ModelProfile): Promise<void> {
|
|
158
|
+
switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
|
|
143
159
|
return this.serialize(() =>
|
|
144
160
|
this.replaceSession(
|
|
145
161
|
"Cannot switch models while a turn or background task is active.",
|
|
146
162
|
() => this.createSessionWithProfile(profile),
|
|
163
|
+
beforeCommit,
|
|
147
164
|
),
|
|
148
165
|
);
|
|
149
166
|
}
|
|
@@ -157,6 +174,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
157
174
|
createTarget: (
|
|
158
175
|
current: ManagedTuiSessionBinding,
|
|
159
176
|
) => Promise<ManagedTuiSessionBinding>,
|
|
177
|
+
beforeCommit?: () => void,
|
|
160
178
|
): Promise<void> {
|
|
161
179
|
const current = this.binding;
|
|
162
180
|
if (!current.runtimeSession.canSwitchSession()) {
|
|
@@ -172,6 +190,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
172
190
|
.catch(() => undefined);
|
|
173
191
|
throw error;
|
|
174
192
|
}
|
|
193
|
+
beforeCommit?.();
|
|
175
194
|
this.binding = target;
|
|
176
195
|
for (const listener of this.listeners) {
|
|
177
196
|
listener();
|
|
@@ -224,6 +243,11 @@ export function managedTuiBinding(input: {
|
|
|
224
243
|
userMessage,
|
|
225
244
|
signal,
|
|
226
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),
|
|
227
251
|
};
|
|
228
252
|
}
|
|
229
253
|
|