talon-agent 1.9.2 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -5
- package/prompts/telegram.md +24 -6
- package/src/__tests__/claude-sdk-options.test.ts +95 -0
- package/src/__tests__/end-turn.test.ts +307 -0
- package/src/__tests__/handlers.test.ts +107 -43
- package/src/__tests__/integration/sdk-stub.test.ts +208 -0
- package/src/__tests__/integration/stub-claude/build-sea.mjs +114 -0
- package/src/__tests__/integration/stub-claude/fake-claude.mjs +352 -0
- package/src/__tests__/integration/stub-claude/helpers.ts +263 -0
- package/src/__tests__/integration/stub-claude/protocol.ts +108 -0
- package/src/__tests__/integration/stub-claude/sea-config.json +7 -0
- package/src/__tests__/integration/talon-bootstrap.ts +206 -0
- package/src/__tests__/integration/talon-functional.test.ts +190 -0
- package/src/__tests__/package.functional.test.ts +178 -0
- package/src/backend/claude-sdk/handler.ts +110 -1
- package/src/backend/claude-sdk/options.ts +59 -1
- package/src/backend/claude-sdk/stream.ts +67 -0
- package/src/core/tools/index.ts +41 -0
- package/src/core/tools/messaging.ts +79 -1
- package/src/core/tools/types.ts +14 -0
- package/src/frontend/teams/index.ts +20 -10
- package/src/frontend/telegram/handlers.ts +16 -12
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types describing the JSON script that drives `fake-claude.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* A script is a list of "turns." Each turn is consumed in order — the Nth user
|
|
5
|
+
* message the SDK sends to the binary triggers the Nth turn's `emit` array.
|
|
6
|
+
* Within a turn, each emitted message is written to stdout as a single JSONL
|
|
7
|
+
* line. The stub binary fills in safe defaults (session_id, message id, usage,
|
|
8
|
+
* stop_reason) so test scripts only need to specify what's relevant.
|
|
9
|
+
*
|
|
10
|
+
* For the SDK's content-block shapes, see the upstream type definitions:
|
|
11
|
+
* node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface StubScript {
|
|
15
|
+
/** Override the auto-generated session id. */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
/** Override the default `SDKControlInitializeResponse` payload. */
|
|
18
|
+
initResponse?: Record<string, unknown>;
|
|
19
|
+
/** Override the default `system/init` event. */
|
|
20
|
+
systemInit?: Record<string, unknown>;
|
|
21
|
+
/** Sequential turns. Turn N is consumed on the Nth user message received. */
|
|
22
|
+
turns?: StubTurn[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StubTurn {
|
|
26
|
+
/** Messages to write to stdout when this turn fires, in order. */
|
|
27
|
+
emit?: StubEmit[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type StubEmit = StubAssistant | StubResult | StubFireHook | StubRaw;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pseudo-emit handled by `fake-claude.mjs` — synthesizes a `hook_callback`
|
|
34
|
+
* control_request to the SDK, mimicking what the real CLI does when it
|
|
35
|
+
* dispatches a tool batch and reaches a hook event. If the hook returns
|
|
36
|
+
* `continue: false`, the stub halts subsequent emits in this turn.
|
|
37
|
+
*/
|
|
38
|
+
export interface StubFireHook {
|
|
39
|
+
type: "_fire_hook";
|
|
40
|
+
event:
|
|
41
|
+
| "PreToolUse"
|
|
42
|
+
| "PostToolUse"
|
|
43
|
+
| "PostToolBatch"
|
|
44
|
+
| "Stop"
|
|
45
|
+
| "SessionStart"
|
|
46
|
+
| "SessionEnd";
|
|
47
|
+
input: Record<string, unknown>;
|
|
48
|
+
tool_use_id?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StubAssistant {
|
|
52
|
+
type: "assistant";
|
|
53
|
+
message?: {
|
|
54
|
+
model?: string;
|
|
55
|
+
content?: ContentBlock[];
|
|
56
|
+
stop_reason?: string;
|
|
57
|
+
stop_sequence?: string | null;
|
|
58
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
59
|
+
};
|
|
60
|
+
parent_tool_use_id?: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StubResult {
|
|
64
|
+
type: "result";
|
|
65
|
+
subtype?:
|
|
66
|
+
| "success"
|
|
67
|
+
| "error_during_execution"
|
|
68
|
+
| "error_max_turns"
|
|
69
|
+
| "error_during_streaming";
|
|
70
|
+
is_error?: boolean;
|
|
71
|
+
result?: string;
|
|
72
|
+
duration_ms?: number;
|
|
73
|
+
duration_api_ms?: number;
|
|
74
|
+
num_turns?: number;
|
|
75
|
+
total_cost_usd?: number;
|
|
76
|
+
usage?: { input_tokens: number; output_tokens: number };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Escape hatch for raw protocol messages (e.g. `system`, `stream_event`,
|
|
81
|
+
* arbitrary control responses) that don't fit the high-level helpers.
|
|
82
|
+
*/
|
|
83
|
+
export interface StubRaw {
|
|
84
|
+
type: string;
|
|
85
|
+
[key: string]: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Content blocks (subset, mirrors @anthropic-ai/sdk Anthropic.Messages) ───
|
|
89
|
+
|
|
90
|
+
export type ContentBlock = TextBlock | ToolUseBlock | ThinkingBlock;
|
|
91
|
+
|
|
92
|
+
export interface TextBlock {
|
|
93
|
+
type: "text";
|
|
94
|
+
text: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ToolUseBlock {
|
|
98
|
+
type: "tool_use";
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
input: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ThinkingBlock {
|
|
105
|
+
type: "thinking";
|
|
106
|
+
thinking: string;
|
|
107
|
+
signature?: string;
|
|
108
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional-test bootstrap for Talon's claude-sdk backend against the stub
|
|
3
|
+
* binary.
|
|
4
|
+
*
|
|
5
|
+
* Calls the **production** `initAgent()` entry point with `claudeBinary`
|
|
6
|
+
* pointed at the stub. No workarounds — the stub advertises its mock models
|
|
7
|
+
* via the standard `SDKControlInitializeResponse.models` field, so
|
|
8
|
+
* `registerClaudeModels()` discovers them by spawning the stub and walking
|
|
9
|
+
* the real init handshake. The result is the entire production code path
|
|
10
|
+
* exercised end-to-end: model discovery, prompt enrichment, system-prompt
|
|
11
|
+
* rebuild, options builder, SDK query, stream processing, dedup, session
|
|
12
|
+
* bookkeeping — only the binary the SDK spawns is fake.
|
|
13
|
+
*
|
|
14
|
+
* Usage in tests:
|
|
15
|
+
*
|
|
16
|
+
* import { runTalonTurn } from "./talon-bootstrap.js";
|
|
17
|
+
* import { assistantText, successResult } from "./stub-claude/helpers.js";
|
|
18
|
+
*
|
|
19
|
+
* const result = await runTalonTurn({
|
|
20
|
+
* prompt: "hello",
|
|
21
|
+
* script: { turns: [{ emit: [assistantText("hi"), successResult()] }] },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* expect(result.text).toBe("hi");
|
|
25
|
+
* expect(result.toolUses).toHaveLength(0);
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { dirname, resolve } from "node:path";
|
|
30
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
|
|
33
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
34
|
+
import { initAgent } from "../../backend/claude-sdk/state.js";
|
|
35
|
+
import { handleMessage } from "../../backend/claude-sdk/handler.js";
|
|
36
|
+
import { resetSession } from "../../storage/sessions.js";
|
|
37
|
+
|
|
38
|
+
import type { StubScript } from "./stub-claude/protocol.js";
|
|
39
|
+
|
|
40
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
// See stub-claude/helpers.ts for the Windows-vs-POSIX rationale.
|
|
42
|
+
const STUB_BINARY = resolve(
|
|
43
|
+
__dirname,
|
|
44
|
+
process.platform === "win32"
|
|
45
|
+
? "stub-claude/fake-claude.exe"
|
|
46
|
+
: "stub-claude/fake-claude.mjs",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let booted = false;
|
|
50
|
+
const bootedTmpDirs: string[] = [];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Idempotent boot — first call initializes the agent, subsequent calls reuse
|
|
54
|
+
* the same in-process state. Safe to call from every test's beforeAll.
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureBooted(): Promise<void> {
|
|
57
|
+
if (booted) return;
|
|
58
|
+
|
|
59
|
+
const workspace = mkdtempSync(resolve(tmpdir(), "talon-stub-workspace-"));
|
|
60
|
+
bootedTmpDirs.push(workspace);
|
|
61
|
+
|
|
62
|
+
const config: TalonConfig = {
|
|
63
|
+
frontend: "terminal",
|
|
64
|
+
backend: "claude",
|
|
65
|
+
claudeBinary: STUB_BINARY,
|
|
66
|
+
model: "claude-sonnet-4-6",
|
|
67
|
+
maxMessageLength: 4000,
|
|
68
|
+
concurrency: 1,
|
|
69
|
+
pulse: false,
|
|
70
|
+
pulseIntervalMs: 300_000,
|
|
71
|
+
heartbeat: false,
|
|
72
|
+
heartbeatIntervalMinutes: 60,
|
|
73
|
+
plugins: [],
|
|
74
|
+
botDisplayName: "Talon (stub)",
|
|
75
|
+
teamsWebhookPort: 19878,
|
|
76
|
+
teamsGraphPollMs: 10_000,
|
|
77
|
+
systemPrompt: "Test system prompt.",
|
|
78
|
+
workspace,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Run the production bootstrap — `initAgent` calls `registerClaudeModels`
|
|
82
|
+
// which spawns the stub binary, performs the init handshake, and pulls the
|
|
83
|
+
// model list out of the SDKControlInitializeResponse. The stub advertises
|
|
84
|
+
// its mock models in `defaultInitResponse.models` (`fake-claude.mjs`), so
|
|
85
|
+
// discovery resolves naturally without any test-side override.
|
|
86
|
+
await initAgent(config);
|
|
87
|
+
booted = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Tear-down hook for tests that want a clean slate. */
|
|
91
|
+
export function teardownBootstrap(): void {
|
|
92
|
+
for (const dir of bootedTmpDirs.splice(0)) {
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
booted = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Per-turn runner ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export interface RunTalonTurnArgs {
|
|
101
|
+
prompt: string;
|
|
102
|
+
script: StubScript;
|
|
103
|
+
chatId?: string;
|
|
104
|
+
senderName?: string;
|
|
105
|
+
isGroup?: boolean;
|
|
106
|
+
/** Reset the session bookkeeping for this chat before running. */
|
|
107
|
+
resetSession?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RunTalonTurnResult {
|
|
111
|
+
/** Full assistant text accumulated across `onTextBlock` calls. */
|
|
112
|
+
text: string;
|
|
113
|
+
/** Tool calls observed via `onToolUse`. */
|
|
114
|
+
toolUses: { name: string; input: Record<string, unknown> }[];
|
|
115
|
+
/** Streaming deltas, in order. */
|
|
116
|
+
streamDeltas: { phase?: string; text: string }[];
|
|
117
|
+
/** Token + duration stats from `handleMessage`. */
|
|
118
|
+
inputTokens: number;
|
|
119
|
+
outputTokens: number;
|
|
120
|
+
durationMs: number;
|
|
121
|
+
/** Lines from the stub's protocol log. */
|
|
122
|
+
protocolLog: string[];
|
|
123
|
+
/** Tmp dir used (cleaned up automatically). */
|
|
124
|
+
tmpDir: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Drives a full Talon turn against the stub binary. Real `handleMessage`
|
|
129
|
+
* runs end-to-end — prompt building, SDK query, stream processing, session
|
|
130
|
+
* persistence — but the SDK talks to the stub instead of the API.
|
|
131
|
+
*/
|
|
132
|
+
export async function runTalonTurn(
|
|
133
|
+
args: RunTalonTurnArgs,
|
|
134
|
+
): Promise<RunTalonTurnResult> {
|
|
135
|
+
await ensureBooted();
|
|
136
|
+
|
|
137
|
+
const {
|
|
138
|
+
prompt,
|
|
139
|
+
script,
|
|
140
|
+
chatId = "stub-chat-" + Math.random().toString(36).slice(2, 10),
|
|
141
|
+
senderName = "TestUser",
|
|
142
|
+
isGroup = false,
|
|
143
|
+
resetSession: shouldResetSession = false,
|
|
144
|
+
} = args;
|
|
145
|
+
|
|
146
|
+
if (shouldResetSession) resetSession(chatId);
|
|
147
|
+
|
|
148
|
+
const tmpDir = mkdtempSync(resolve(tmpdir(), "talon-turn-"));
|
|
149
|
+
const scriptPath = resolve(tmpDir, "script.json");
|
|
150
|
+
const logPath = resolve(tmpDir, "protocol.log");
|
|
151
|
+
writeFileSync(scriptPath, JSON.stringify(script));
|
|
152
|
+
|
|
153
|
+
process.env.STUB_CLAUDE_SCRIPT = scriptPath;
|
|
154
|
+
process.env.STUB_CLAUDE_LOG = logPath;
|
|
155
|
+
process.env.STUB_CLAUDE_TIMEOUT_MS = "10000";
|
|
156
|
+
|
|
157
|
+
const textChunks: string[] = [];
|
|
158
|
+
const toolUses: { name: string; input: Record<string, unknown> }[] = [];
|
|
159
|
+
const streamDeltas: { phase?: string; text: string }[] = [];
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const result = await handleMessage({
|
|
163
|
+
chatId,
|
|
164
|
+
text: prompt,
|
|
165
|
+
senderName,
|
|
166
|
+
isGroup,
|
|
167
|
+
onTextBlock: async (text) => {
|
|
168
|
+
textChunks.push(text);
|
|
169
|
+
},
|
|
170
|
+
onToolUse: (name, input) => {
|
|
171
|
+
toolUses.push({ name, input });
|
|
172
|
+
},
|
|
173
|
+
onStreamDelta: (accumulated, phase) => {
|
|
174
|
+
streamDeltas.push({ phase, text: accumulated });
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
let protocolLog: string[] = [];
|
|
179
|
+
try {
|
|
180
|
+
const { readFileSync } = await import("node:fs");
|
|
181
|
+
protocolLog = readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
182
|
+
} catch {
|
|
183
|
+
/* no log if stub never started */
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
text: textChunks.join(""),
|
|
188
|
+
toolUses,
|
|
189
|
+
streamDeltas,
|
|
190
|
+
inputTokens: result.inputTokens,
|
|
191
|
+
outputTokens: result.outputTokens,
|
|
192
|
+
durationMs: result.durationMs,
|
|
193
|
+
protocolLog,
|
|
194
|
+
tmpDir,
|
|
195
|
+
};
|
|
196
|
+
} finally {
|
|
197
|
+
delete process.env.STUB_CLAUDE_SCRIPT;
|
|
198
|
+
delete process.env.STUB_CLAUDE_LOG;
|
|
199
|
+
delete process.env.STUB_CLAUDE_TIMEOUT_MS;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Cleanup helper for tests that want to remove their tmp dirs. */
|
|
204
|
+
export function cleanupTurn(result: RunTalonTurnResult): void {
|
|
205
|
+
rmSync(result.tmpDir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional tests: drives Talon's `handleMessage` end-to-end against the stub
|
|
3
|
+
* binary. Tests cover real conversational structures — single-turn replies,
|
|
4
|
+
* tool-call dispatch, multi-turn dialogue, error paths.
|
|
5
|
+
*
|
|
6
|
+
* Unlike `sdk-stub.test.ts` which pokes `query()` directly with a custom hook,
|
|
7
|
+
* these tests run the full Talon code path: prompt building, system-prompt
|
|
8
|
+
* rebuild, options builder (including the production PostToolBatch hook),
|
|
9
|
+
* stream processing, session bookkeeping. The stub binary plays the role of
|
|
10
|
+
* the live API.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
runTalonTurn,
|
|
16
|
+
cleanupTurn,
|
|
17
|
+
teardownBootstrap,
|
|
18
|
+
} from "./talon-bootstrap.js";
|
|
19
|
+
import {
|
|
20
|
+
assistantText,
|
|
21
|
+
endTurnWithText,
|
|
22
|
+
successResult,
|
|
23
|
+
} from "./stub-claude/helpers.js";
|
|
24
|
+
|
|
25
|
+
/** Pull delivered text from end_turn tool calls — same path the bridge uses. */
|
|
26
|
+
function deliveredText(
|
|
27
|
+
toolUses: { name: string; input: Record<string, unknown> }[],
|
|
28
|
+
): string {
|
|
29
|
+
return toolUses
|
|
30
|
+
.filter((t) => t.name.endsWith("end_turn") || t.name === "end_turn")
|
|
31
|
+
.map((t) => (typeof t.input.text === "string" ? t.input.text : ""))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Skip if the stub binary isn't on disk (e.g. someone forgot to run
|
|
36
|
+
// `npm run build:stub-sea` on Windows). On POSIX the .mjs source is always
|
|
37
|
+
// shipped so this is effectively always true.
|
|
38
|
+
import { existsSync } from "node:fs";
|
|
39
|
+
import { resolve as resolvePath, dirname as dirnamePath } from "node:path";
|
|
40
|
+
import { fileURLToPath as fileUrl } from "node:url";
|
|
41
|
+
const __testDir = dirnamePath(fileUrl(import.meta.url));
|
|
42
|
+
const stubReady = existsSync(
|
|
43
|
+
resolvePath(
|
|
44
|
+
__testDir,
|
|
45
|
+
process.platform === "win32"
|
|
46
|
+
? "stub-claude/fake-claude.exe"
|
|
47
|
+
: "stub-claude/fake-claude.mjs",
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
describe.skipIf(!stubReady)("Talon functional — bootstrap", () => {
|
|
52
|
+
it("discovers the stub's advertised models through the production initAgent path", async () => {
|
|
53
|
+
// No `registerClaudeModelsStatic` workaround — the stub advertises models
|
|
54
|
+
// in its init handshake response, the production model-discovery path
|
|
55
|
+
// pulls them out of `q.supportedModels()`, tests run through unmodified
|
|
56
|
+
// bootstrap.
|
|
57
|
+
await import("./talon-bootstrap.js").then((m) => m.ensureBooted());
|
|
58
|
+
const { getModels } = await import("../../core/models.js");
|
|
59
|
+
const ids = getModels().map((m) => m.id);
|
|
60
|
+
expect(ids).toContain("claude-sonnet-4-6");
|
|
61
|
+
expect(ids).toContain("default");
|
|
62
|
+
// The stub also advertises an opus model; if discovery is real, we see it.
|
|
63
|
+
expect(ids).toContain("claude-opus-4-7");
|
|
64
|
+
}, 20000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe.skipIf(!stubReady)("Talon functional — single-turn", () => {
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
teardownBootstrap();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("delivers a final reply via end_turn and triggers PostToolBatch", async () => {
|
|
73
|
+
const [assistant, hook] = endTurnWithText("hello back");
|
|
74
|
+
const result = await runTalonTurn({
|
|
75
|
+
prompt: "say hello",
|
|
76
|
+
script: {
|
|
77
|
+
turns: [
|
|
78
|
+
{
|
|
79
|
+
emit: [assistant, hook, successResult("hello back")],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
resetSession: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The stub delivered via end_turn. The handler should have captured the
|
|
87
|
+
// tool call (which is how the bridge would deliver to the user).
|
|
88
|
+
expect(deliveredText(result.toolUses)).toBe("hello back");
|
|
89
|
+
// No plain text blocks should leak — production contract is "no scratchpad".
|
|
90
|
+
expect(result.text).toBe("");
|
|
91
|
+
expect(result.protocolLog.length).toBeGreaterThan(0);
|
|
92
|
+
cleanupTurn(result);
|
|
93
|
+
}, 20000);
|
|
94
|
+
|
|
95
|
+
it("treats raw assistant text without end_turn as scratchpad (dropped)", async () => {
|
|
96
|
+
// This documents the production contract: text not routed through end_turn
|
|
97
|
+
// is private scratchpad. The flow-violation retry path would fire too in
|
|
98
|
+
// production; we don't assert on the retry here, only on first-turn drop.
|
|
99
|
+
const result = await runTalonTurn({
|
|
100
|
+
prompt: "send raw text",
|
|
101
|
+
script: {
|
|
102
|
+
turns: [
|
|
103
|
+
{
|
|
104
|
+
emit: [assistantText("this should be dropped"), successResult()],
|
|
105
|
+
},
|
|
106
|
+
// Second turn handles the scratchpad-violation re-prompt that fires:
|
|
107
|
+
// we just emit a clean end_turn() to close.
|
|
108
|
+
{
|
|
109
|
+
emit: [...endTurnWithText(""), successResult()],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
resetSession: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The raw text never reaches onTextBlock — it's private scratchpad.
|
|
117
|
+
expect(result.text).toBe("");
|
|
118
|
+
cleanupTurn(result);
|
|
119
|
+
}, 25000);
|
|
120
|
+
|
|
121
|
+
it("emits progress text before tool calls via onTextBlock", async () => {
|
|
122
|
+
// Real production pattern: model writes a brief "thinking out loud" text
|
|
123
|
+
// chunk before calling a tool. That text flows through `progressTexts`
|
|
124
|
+
// → `onTextBlock`, separate from the trailing/scratchpad path.
|
|
125
|
+
const [endTurn, hook] = endTurnWithText("done");
|
|
126
|
+
const result = await runTalonTurn({
|
|
127
|
+
prompt: "step then deliver",
|
|
128
|
+
script: {
|
|
129
|
+
turns: [
|
|
130
|
+
{
|
|
131
|
+
emit: [
|
|
132
|
+
{
|
|
133
|
+
type: "assistant",
|
|
134
|
+
message: {
|
|
135
|
+
stop_reason: "tool_use",
|
|
136
|
+
content: [
|
|
137
|
+
{ type: "text", text: "Working on it... " },
|
|
138
|
+
{
|
|
139
|
+
type: "tool_use",
|
|
140
|
+
id: "tu_progress",
|
|
141
|
+
name: "mcp__telegram-tools__end_turn",
|
|
142
|
+
input: { text: "done" },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
hook,
|
|
148
|
+
successResult("done"),
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
resetSession: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.text).toContain("Working on it");
|
|
157
|
+
expect(deliveredText(result.toolUses)).toBe("done");
|
|
158
|
+
// We crafted the assistant message manually instead of using the helper.
|
|
159
|
+
void endTurn;
|
|
160
|
+
cleanupTurn(result);
|
|
161
|
+
}, 20000);
|
|
162
|
+
|
|
163
|
+
it("records token usage from the stub's result message", async () => {
|
|
164
|
+
const [assistant, hook] = endTurnWithText("ok");
|
|
165
|
+
const result = await runTalonTurn({
|
|
166
|
+
prompt: "test usage",
|
|
167
|
+
script: {
|
|
168
|
+
turns: [
|
|
169
|
+
{
|
|
170
|
+
emit: [
|
|
171
|
+
assistant,
|
|
172
|
+
hook,
|
|
173
|
+
{
|
|
174
|
+
type: "result",
|
|
175
|
+
subtype: "success",
|
|
176
|
+
result: "ok",
|
|
177
|
+
usage: { input_tokens: 42, output_tokens: 17 },
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
resetSession: true,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.inputTokens).toBe(42);
|
|
187
|
+
expect(result.outputTokens).toBe(17);
|
|
188
|
+
cleanupTurn(result);
|
|
189
|
+
}, 20000);
|
|
190
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const REPO_ROOT = resolve(import.meta.dirname, "../..");
|
|
8
|
+
// 4 minutes — Windows runners regularly take 3+ minutes for `npm install` on
|
|
9
|
+
// the published tarball (cold cache + Windows fs latency); 3min was right at
|
|
10
|
+
// the edge. Bumped from 180k after a post-merge timeout on `main`
|
|
11
|
+
// (run 25603848100, 2026-05-09).
|
|
12
|
+
const FUNCTIONAL_TIMEOUT_MS = 240_000;
|
|
13
|
+
const NPM_CLI = process.env.npm_execpath;
|
|
14
|
+
|
|
15
|
+
type RunResult = {
|
|
16
|
+
code: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const workDirs: string[] = [];
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const dir of workDirs.splice(0)) {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}, 60_000);
|
|
28
|
+
|
|
29
|
+
function makeWorkDir(name: string): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), `talon-${name}-`));
|
|
31
|
+
workDirs.push(dir);
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function childEnv(home: string): NodeJS.ProcessEnv {
|
|
36
|
+
return {
|
|
37
|
+
...process.env,
|
|
38
|
+
HOME: home,
|
|
39
|
+
USERPROFILE: home,
|
|
40
|
+
TALON_QUIET: "1",
|
|
41
|
+
NO_COLOR: "1",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function run(
|
|
46
|
+
command: string,
|
|
47
|
+
args: string[],
|
|
48
|
+
options: {
|
|
49
|
+
cwd?: string;
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
shell?: boolean;
|
|
53
|
+
} = {},
|
|
54
|
+
): RunResult {
|
|
55
|
+
const child = spawnSync(command, args, {
|
|
56
|
+
cwd: options.cwd ?? REPO_ROOT,
|
|
57
|
+
env: options.env ?? process.env,
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: options.timeoutMs ?? 30_000,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
shell: options.shell ?? false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
code: child.status,
|
|
66
|
+
stdout: child.stdout ?? "",
|
|
67
|
+
stderr: child.error
|
|
68
|
+
? `${child.stderr ?? ""}\n${child.error.message}`
|
|
69
|
+
: (child.stderr ?? ""),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runNpm(
|
|
74
|
+
args: string[],
|
|
75
|
+
options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
|
76
|
+
): RunResult {
|
|
77
|
+
if (NPM_CLI) return run(process.execPath, [NPM_CLI, ...args], options);
|
|
78
|
+
return run(process.platform === "win32" ? "npm.cmd" : "npm", args, {
|
|
79
|
+
...options,
|
|
80
|
+
shell: process.platform === "win32",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function expectOk(result: RunResult): void {
|
|
85
|
+
expectExitOk(result);
|
|
86
|
+
expect(
|
|
87
|
+
result.stderr,
|
|
88
|
+
`process exited with ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
89
|
+
).toBe("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expectExitOk(result: RunResult): void {
|
|
93
|
+
expect(
|
|
94
|
+
result.stderr,
|
|
95
|
+
`process exited with ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
96
|
+
).not.toContain("spawnSync");
|
|
97
|
+
expect(result.code).toBe(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function packInto(dir: string): string {
|
|
101
|
+
const result = runNpm(["pack", "--json", "--pack-destination", dir], {
|
|
102
|
+
timeoutMs: 30_000,
|
|
103
|
+
});
|
|
104
|
+
expectOk(result);
|
|
105
|
+
|
|
106
|
+
const entries = JSON.parse(result.stdout) as Array<{
|
|
107
|
+
filename: string;
|
|
108
|
+
files: Array<{ path: string }>;
|
|
109
|
+
}>;
|
|
110
|
+
const pack = entries[0];
|
|
111
|
+
expect(pack).toBeDefined();
|
|
112
|
+
|
|
113
|
+
const packedFiles = new Set(pack.files.map((file) => file.path));
|
|
114
|
+
for (const required of [
|
|
115
|
+
"bin/talon.js",
|
|
116
|
+
"src/cli.ts",
|
|
117
|
+
"src/util/mcp-launcher.mjs",
|
|
118
|
+
"prompts/base.md",
|
|
119
|
+
"tsconfig.json",
|
|
120
|
+
]) {
|
|
121
|
+
expect(packedFiles.has(required), `${required} should be packed`).toBe(
|
|
122
|
+
true,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return join(dir, pack.filename);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("package functional smoke tests", () => {
|
|
130
|
+
it(
|
|
131
|
+
"published tarball includes runtime assets and exposes a working CLI",
|
|
132
|
+
() => {
|
|
133
|
+
const packDir = makeWorkDir("pack");
|
|
134
|
+
const installDir = makeWorkDir("install");
|
|
135
|
+
const homeDir = makeWorkDir("home");
|
|
136
|
+
const tarball = packInto(packDir);
|
|
137
|
+
|
|
138
|
+
writeFileSync(join(installDir, "package.json"), "{}\n");
|
|
139
|
+
expectExitOk(
|
|
140
|
+
runNpm(
|
|
141
|
+
["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball],
|
|
142
|
+
{
|
|
143
|
+
cwd: installDir,
|
|
144
|
+
timeoutMs: FUNCTIONAL_TIMEOUT_MS,
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const cli = join(
|
|
150
|
+
installDir,
|
|
151
|
+
"node_modules",
|
|
152
|
+
"talon-agent",
|
|
153
|
+
"bin",
|
|
154
|
+
"talon.js",
|
|
155
|
+
);
|
|
156
|
+
const help = run(process.execPath, [cli, "--help"], {
|
|
157
|
+
cwd: installDir,
|
|
158
|
+
env: childEnv(homeDir),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expectOk(help);
|
|
162
|
+
expect(help.stdout).toContain("Usage: talon [command]");
|
|
163
|
+
expect(help.stdout).toContain("doctor");
|
|
164
|
+
},
|
|
165
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
it("source CLI status command is non-interactive and isolated from the real home directory", () => {
|
|
169
|
+
const homeDir = makeWorkDir("source-home");
|
|
170
|
+
const result = run(process.execPath, ["bin/talon.js", "status"], {
|
|
171
|
+
env: childEnv(homeDir),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expectOk(result);
|
|
175
|
+
expect(result.stdout).toContain("Stopped");
|
|
176
|
+
expect(result.stdout).toContain("Run talon setup");
|
|
177
|
+
});
|
|
178
|
+
});
|