switchroom 0.14.20 → 0.14.22
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/dist/agent-scheduler/index.js +2 -3
- package/dist/auth-broker/index.js +2 -3
- package/dist/cli/notion-write-pretool.mjs +2 -3
- package/dist/cli/switchroom.js +16 -8
- package/dist/host-control/main.js +2 -3
- package/dist/vault/approvals/kernel-server.js +2 -3
- package/dist/vault/broker/server.js +2 -3
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +11 -24
- package/profiles/_shared/telegram-style.md.hbs +2 -2
- package/profiles/default/CLAUDE.md.hbs +4 -1
- package/skills/switchroom-runtime/SKILL.md +6 -16
- package/telegram-plugin/agent-dir.ts +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +655 -514
- package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
- package/telegram-plugin/gateway/gateway.ts +246 -83
- package/telegram-plugin/gateway/inbound-spool.ts +15 -0
- package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
- package/telegram-plugin/registry/turns-schema.ts +138 -33
- package/telegram-plugin/stream-reply-handler.ts +1 -11
- package/telegram-plugin/tests/agent-dir.test.ts +25 -0
- package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
- package/telegram-plugin/tests/e2e.test.ts +2 -77
- package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
- package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
- package/telegram-plugin/tests/races.test.ts +0 -26
- package/telegram-plugin/tests/registry-turns.test.ts +106 -29
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tests/turns-writer.test.ts +16 -6
- package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
- package/telegram-plugin/tool-activity-summary.ts +55 -0
- package/telegram-plugin/uat/assertions.ts +53 -0
- package/telegram-plugin/uat/driver.ts +30 -0
- package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
- package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
- package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
- package/telegram-plugin/worker-activity-feed.ts +11 -5
- package/telegram-plugin/handoff-continuity.ts +0 -206
- package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure helpers for the session-handoff continuity line.
|
|
3
|
-
*
|
|
4
|
-
* On session start, the telegram plugin reads `$AGENT_DIR/.handoff-topic`
|
|
5
|
-
* (written by the summarizer Stop hook). On the FIRST assistant reply
|
|
6
|
-
* of the new session the plugin prepends a subtle one-liner:
|
|
7
|
-
*
|
|
8
|
-
* ↩️ Picked up where we left off, <topic>
|
|
9
|
-
*
|
|
10
|
-
* The sidecar is consumed (read + deleted) so the line only fires once.
|
|
11
|
-
* All helpers here are filesystem-only or env-only — no Telegram side
|
|
12
|
-
* effects — which keeps them unit-testable in isolation.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFileSync, unlinkSync, existsSync, writeFileSync, renameSync } from "node:fs";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
|
|
18
|
-
export const TOPIC_DISPLAY_MAX = 117;
|
|
19
|
-
export const HANDOFF_TOPIC_FILENAME = ".handoff-topic";
|
|
20
|
-
/**
|
|
21
|
-
* Secondary sidecar written by the progress-card driver on every
|
|
22
|
-
* successful turn_end. The file is overwritten each turn so it always
|
|
23
|
-
* reflects the most-recent turn. Used as a fallback source for the
|
|
24
|
-
* continuity line when the Stop-hook summarizer hasn't run yet (e.g.
|
|
25
|
-
* crash, mid-session restart, summarizer failure). Deleted alongside
|
|
26
|
-
* `.handoff-topic` in `consumeHandoffTopic`.
|
|
27
|
-
*/
|
|
28
|
-
export const LAST_TURN_SUMMARY_FILENAME = ".last-turn-summary";
|
|
29
|
-
|
|
30
|
-
export function resolveAgentDirFromEnv(): string | null {
|
|
31
|
-
const state = process.env.TELEGRAM_STATE_DIR;
|
|
32
|
-
if (!state || state.trim().length === 0) return null;
|
|
33
|
-
return dirname(state);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Read the handoff topic file if present. Returns the trimmed first
|
|
38
|
-
* non-empty line, truncated to TOPIC_DISPLAY_MAX with an ellipsis.
|
|
39
|
-
* Missing, empty, or unreadable → null.
|
|
40
|
-
*/
|
|
41
|
-
export function readHandoffTopic(agentDir: string): string | null {
|
|
42
|
-
const p = join(agentDir, HANDOFF_TOPIC_FILENAME);
|
|
43
|
-
if (!existsSync(p)) return null;
|
|
44
|
-
let raw: string;
|
|
45
|
-
try {
|
|
46
|
-
raw = readFileSync(p, "utf-8");
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
51
|
-
if (lines.length === 0) return null;
|
|
52
|
-
let topic = lines[0];
|
|
53
|
-
if (topic.length > TOPIC_DISPLAY_MAX) {
|
|
54
|
-
topic = topic.slice(0, TOPIC_DISPLAY_MAX) + "…";
|
|
55
|
-
}
|
|
56
|
-
return topic;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Read the per-turn summary file if present (written by the progress-
|
|
61
|
-
* card driver on every turn_end). Returns the trimmed first non-empty
|
|
62
|
-
* line, truncated like `readHandoffTopic`. The file is always
|
|
63
|
-
* overwritten so it reflects the most-recent completed turn.
|
|
64
|
-
*/
|
|
65
|
-
export function readLastTurnSummary(agentDir: string): string | null {
|
|
66
|
-
const p = join(agentDir, LAST_TURN_SUMMARY_FILENAME);
|
|
67
|
-
if (!existsSync(p)) return null;
|
|
68
|
-
let raw: string;
|
|
69
|
-
try {
|
|
70
|
-
raw = readFileSync(p, "utf-8");
|
|
71
|
-
} catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
75
|
-
if (lines.length === 0) return null;
|
|
76
|
-
let topic = lines[0];
|
|
77
|
-
if (topic.length > TOPIC_DISPLAY_MAX) {
|
|
78
|
-
topic = topic.slice(0, TOPIC_DISPLAY_MAX) + "…";
|
|
79
|
-
}
|
|
80
|
-
return topic;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Read + delete the topic file atomically (best-effort). A second call
|
|
85
|
-
* returns null even if the first succeeded — the sidecar is one-shot.
|
|
86
|
-
*
|
|
87
|
-
* Fallback: if no `.handoff-topic` is present (summarizer didn't run,
|
|
88
|
-
* crashed, or the session was restarted mid-loop), try the
|
|
89
|
-
* `.last-turn-summary` sidecar written by the progress-card driver.
|
|
90
|
-
* Both sidecars get removed on consume so the continuity line only
|
|
91
|
-
* fires once per resume.
|
|
92
|
-
*/
|
|
93
|
-
export function consumeHandoffTopic(agentDir: string): string | null {
|
|
94
|
-
const primary = readHandoffTopic(agentDir);
|
|
95
|
-
const primaryPath = join(agentDir, HANDOFF_TOPIC_FILENAME);
|
|
96
|
-
const fallbackPath = join(agentDir, LAST_TURN_SUMMARY_FILENAME);
|
|
97
|
-
|
|
98
|
-
// Always remove the per-turn summary when we consume — otherwise a
|
|
99
|
-
// later session restart would still see a stale entry even after the
|
|
100
|
-
// continuity line fired.
|
|
101
|
-
const removeFallback = (): void => {
|
|
102
|
-
try {
|
|
103
|
-
unlinkSync(fallbackPath);
|
|
104
|
-
} catch {
|
|
105
|
-
/* already gone */
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
if (primary !== null) {
|
|
110
|
-
try {
|
|
111
|
-
unlinkSync(primaryPath);
|
|
112
|
-
} catch {
|
|
113
|
-
/* already gone */
|
|
114
|
-
}
|
|
115
|
-
removeFallback();
|
|
116
|
-
return primary;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const fallback = readLastTurnSummary(agentDir);
|
|
120
|
-
if (fallback !== null) {
|
|
121
|
-
removeFallback();
|
|
122
|
-
return fallback;
|
|
123
|
-
}
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Atomically overwrite `.last-turn-summary` with a single-line summary.
|
|
129
|
-
* Called by the progress-card driver on every turn_end. Best-effort: any
|
|
130
|
-
* write failure is swallowed (logged by the caller if desired) — a
|
|
131
|
-
* missing fallback file is recoverable, a half-written one is not.
|
|
132
|
-
*
|
|
133
|
-
* The summary is the natural plain-text signature of a completed turn:
|
|
134
|
-
* `<N tools, Ys> — <user request (truncated)>`
|
|
135
|
-
* Callers should pass a pre-built line; this function handles only the
|
|
136
|
-
* atomic write + first-line discipline.
|
|
137
|
-
*/
|
|
138
|
-
export function writeLastTurnSummary(agentDir: string, summary: string): void {
|
|
139
|
-
const line = summary.split(/\r?\n/)[0]?.trim() ?? "";
|
|
140
|
-
if (line.length === 0) return;
|
|
141
|
-
const final = line.length > TOPIC_DISPLAY_MAX
|
|
142
|
-
? line.slice(0, TOPIC_DISPLAY_MAX) + "…"
|
|
143
|
-
: line;
|
|
144
|
-
const p = join(agentDir, LAST_TURN_SUMMARY_FILENAME);
|
|
145
|
-
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
146
|
-
try {
|
|
147
|
-
writeFileSync(tmp, final + "\n", "utf-8");
|
|
148
|
-
renameSync(tmp, p);
|
|
149
|
-
} catch {
|
|
150
|
-
/* best-effort — continuity line is purely cosmetic */
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Reads SWITCHROOM_HANDOFF_SHOW_LINE. Defaults to true when unset so users
|
|
156
|
-
* opt out explicitly via switchroom.yaml rather than opt in.
|
|
157
|
-
*/
|
|
158
|
-
export function shouldShowHandoffLine(): boolean {
|
|
159
|
-
const v = process.env.SWITCHROOM_HANDOFF_SHOW_LINE;
|
|
160
|
-
if (v === undefined) return true;
|
|
161
|
-
return v.toLowerCase() !== "false";
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export type HandoffFormat = "html" | "markdownv2" | "text";
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Format the continuity line for the requested outbound format. The
|
|
168
|
-
* returned string already includes the trailing `\n\n` separator so the
|
|
169
|
-
* caller can concatenate directly with the assistant's reply body.
|
|
170
|
-
*
|
|
171
|
-
* HTML: wraps in <i>…</i>. MarkdownV2: wraps in _…_ with escaping.
|
|
172
|
-
* text: plain. All variants prefix the ↩️ emoji.
|
|
173
|
-
*/
|
|
174
|
-
export function formatHandoffLine(
|
|
175
|
-
topic: string,
|
|
176
|
-
format: HandoffFormat,
|
|
177
|
-
): string {
|
|
178
|
-
// Comma instead of em-dash: the framework-emitted prefix is
|
|
179
|
-
// concatenated AFTER scrubVoice runs on the model body (gateway.ts
|
|
180
|
-
// executeReply), so any em-dash here bypasses the v0.13.20 voice
|
|
181
|
-
// scrub. Replacing at the template source is one mechanical change
|
|
182
|
-
// that closes the dominant residual em-dash leak (16 of 17 dashed
|
|
183
|
-
// messages on test-harness were this template per 2026-05-24 audit).
|
|
184
|
-
const prefix = "↩️ Picked up where we left off, ";
|
|
185
|
-
if (format === "html") {
|
|
186
|
-
return `<i>${prefix}${escapeHtml(topic)}</i>\n\n`;
|
|
187
|
-
}
|
|
188
|
-
if (format === "markdownv2") {
|
|
189
|
-
const escaped = escapeMarkdownV2(topic);
|
|
190
|
-
const prefixEsc = escapeMarkdownV2(prefix);
|
|
191
|
-
return `_${prefixEsc}${escaped}_\n\n`;
|
|
192
|
-
}
|
|
193
|
-
return `${prefix}${topic}\n\n`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function escapeHtml(s: string): string {
|
|
197
|
-
return s
|
|
198
|
-
.replace(/&/g, "&")
|
|
199
|
-
.replace(/</g, "<")
|
|
200
|
-
.replace(/>/g, ">");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const MDV2_SPECIALS = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
|
|
204
|
-
function escapeMarkdownV2(s: string): string {
|
|
205
|
-
return s.replace(MDV2_SPECIALS, (m) => "\\" + m);
|
|
206
|
-
}
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
resolveAgentDirFromEnv,
|
|
7
|
-
readHandoffTopic,
|
|
8
|
-
consumeHandoffTopic,
|
|
9
|
-
shouldShowHandoffLine,
|
|
10
|
-
formatHandoffLine,
|
|
11
|
-
readLastTurnSummary,
|
|
12
|
-
writeLastTurnSummary,
|
|
13
|
-
TOPIC_DISPLAY_MAX,
|
|
14
|
-
HANDOFF_TOPIC_FILENAME,
|
|
15
|
-
LAST_TURN_SUMMARY_FILENAME,
|
|
16
|
-
} from "../handoff-continuity.js";
|
|
17
|
-
|
|
18
|
-
describe("resolveAgentDirFromEnv", () => {
|
|
19
|
-
const prior = process.env.TELEGRAM_STATE_DIR;
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
if (prior === undefined) delete process.env.TELEGRAM_STATE_DIR;
|
|
22
|
-
else process.env.TELEGRAM_STATE_DIR = prior;
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("returns dirname of TELEGRAM_STATE_DIR", () => {
|
|
26
|
-
process.env.TELEGRAM_STATE_DIR = "/foo/bar/agent/telegram";
|
|
27
|
-
expect(resolveAgentDirFromEnv()).toBe("/foo/bar/agent");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("returns null when env unset", () => {
|
|
31
|
-
delete process.env.TELEGRAM_STATE_DIR;
|
|
32
|
-
expect(resolveAgentDirFromEnv()).toBeNull();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("returns null when env is empty string", () => {
|
|
36
|
-
process.env.TELEGRAM_STATE_DIR = " ";
|
|
37
|
-
expect(resolveAgentDirFromEnv()).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("readHandoffTopic", () => {
|
|
42
|
-
let tmp: string;
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
tmp = mkdtempSync(join(tmpdir(), "handoff-topic-"));
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
afterEach(() => {
|
|
49
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("returns null when the file is missing", () => {
|
|
53
|
-
expect(readHandoffTopic(tmp)).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("returns null when the file is empty", () => {
|
|
57
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "");
|
|
58
|
-
expect(readHandoffTopic(tmp)).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("returns the trimmed single-line topic", () => {
|
|
62
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), " debugging the plugin ");
|
|
63
|
-
expect(readHandoffTopic(tmp)).toBe("debugging the plugin");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("takes the first non-empty line when the file is multi-line", () => {
|
|
67
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "\n\nfirst topic\nsecond\n");
|
|
68
|
-
expect(readHandoffTopic(tmp)).toBe("first topic");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("truncates topics longer than the display max", () => {
|
|
72
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "x".repeat(TOPIC_DISPLAY_MAX + 20));
|
|
73
|
-
const got = readHandoffTopic(tmp)!;
|
|
74
|
-
expect(got.endsWith("…")).toBe(true);
|
|
75
|
-
expect(got.length).toBe(TOPIC_DISPLAY_MAX + 1);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("consumeHandoffTopic", () => {
|
|
80
|
-
let tmp: string;
|
|
81
|
-
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
tmp = mkdtempSync(join(tmpdir(), "handoff-consume-"));
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
afterEach(() => {
|
|
87
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("returns the topic and deletes the file", () => {
|
|
91
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "once");
|
|
92
|
-
expect(consumeHandoffTopic(tmp)).toBe("once");
|
|
93
|
-
expect(existsSync(join(tmp, HANDOFF_TOPIC_FILENAME))).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("returns null on the second call (one-shot)", () => {
|
|
97
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "once");
|
|
98
|
-
expect(consumeHandoffTopic(tmp)).toBe("once");
|
|
99
|
-
expect(consumeHandoffTopic(tmp)).toBeNull();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("returns null when the file is missing", () => {
|
|
103
|
-
expect(consumeHandoffTopic(tmp)).toBeNull();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("falls back to .last-turn-summary when no .handoff-topic exists", () => {
|
|
107
|
-
writeFileSync(join(tmp, LAST_TURN_SUMMARY_FILENAME), "3 tools, 12s — fix the bug");
|
|
108
|
-
expect(consumeHandoffTopic(tmp)).toBe("3 tools, 12s — fix the bug");
|
|
109
|
-
// Fallback sidecar removed so a repeat call doesn't refire.
|
|
110
|
-
expect(existsSync(join(tmp, LAST_TURN_SUMMARY_FILENAME))).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("prefers .handoff-topic when both files exist", () => {
|
|
114
|
-
writeFileSync(join(tmp, HANDOFF_TOPIC_FILENAME), "llm summary");
|
|
115
|
-
writeFileSync(join(tmp, LAST_TURN_SUMMARY_FILENAME), "card summary");
|
|
116
|
-
expect(consumeHandoffTopic(tmp)).toBe("llm summary");
|
|
117
|
-
// Both get removed so neither stays around to fire on a later restart.
|
|
118
|
-
expect(existsSync(join(tmp, HANDOFF_TOPIC_FILENAME))).toBe(false);
|
|
119
|
-
expect(existsSync(join(tmp, LAST_TURN_SUMMARY_FILENAME))).toBe(false);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe("readLastTurnSummary", () => {
|
|
124
|
-
let tmp: string;
|
|
125
|
-
|
|
126
|
-
beforeEach(() => {
|
|
127
|
-
tmp = mkdtempSync(join(tmpdir(), "handoff-lastturn-"));
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
afterEach(() => {
|
|
131
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("returns null when missing", () => {
|
|
135
|
-
expect(readLastTurnSummary(tmp)).toBeNull();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("reads the first non-empty line", () => {
|
|
139
|
-
writeFileSync(join(tmp, LAST_TURN_SUMMARY_FILENAME), "\n5 tools, 47s — run evals\nmore\n");
|
|
140
|
-
expect(readLastTurnSummary(tmp)).toBe("5 tools, 47s — run evals");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("truncates long summaries", () => {
|
|
144
|
-
writeFileSync(join(tmp, LAST_TURN_SUMMARY_FILENAME), "x".repeat(TOPIC_DISPLAY_MAX + 10));
|
|
145
|
-
const got = readLastTurnSummary(tmp)!;
|
|
146
|
-
expect(got.endsWith("…")).toBe(true);
|
|
147
|
-
expect(got.length).toBe(TOPIC_DISPLAY_MAX + 1);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe("writeLastTurnSummary", () => {
|
|
152
|
-
let tmp: string;
|
|
153
|
-
|
|
154
|
-
beforeEach(() => {
|
|
155
|
-
tmp = mkdtempSync(join(tmpdir(), "handoff-write-"));
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
afterEach(() => {
|
|
159
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("writes a round-trippable summary", () => {
|
|
163
|
-
writeLastTurnSummary(tmp, "7 tools, 02:04 — ship the progress card");
|
|
164
|
-
expect(readLastTurnSummary(tmp)).toBe("7 tools, 02:04 — ship the progress card");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("overwrites on subsequent calls (always reflects the most-recent turn)", () => {
|
|
168
|
-
writeLastTurnSummary(tmp, "first turn");
|
|
169
|
-
writeLastTurnSummary(tmp, "second turn");
|
|
170
|
-
expect(readLastTurnSummary(tmp)).toBe("second turn");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("writes only the first line even when input is multi-line", () => {
|
|
174
|
-
writeLastTurnSummary(tmp, "headline\nlots more context goes here\nstill more");
|
|
175
|
-
expect(readLastTurnSummary(tmp)).toBe("headline");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("truncates over-long input at TOPIC_DISPLAY_MAX + ellipsis", () => {
|
|
179
|
-
writeLastTurnSummary(tmp, "x".repeat(TOPIC_DISPLAY_MAX + 50));
|
|
180
|
-
const got = readLastTurnSummary(tmp)!;
|
|
181
|
-
expect(got.length).toBe(TOPIC_DISPLAY_MAX + 1);
|
|
182
|
-
expect(got.endsWith("…")).toBe(true);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("no-ops on empty / whitespace-only input (never writes an empty file)", () => {
|
|
186
|
-
writeLastTurnSummary(tmp, " ");
|
|
187
|
-
expect(existsSync(join(tmp, LAST_TURN_SUMMARY_FILENAME))).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("shouldShowHandoffLine", () => {
|
|
192
|
-
const prior = process.env.SWITCHROOM_HANDOFF_SHOW_LINE;
|
|
193
|
-
afterEach(() => {
|
|
194
|
-
if (prior === undefined) delete process.env.SWITCHROOM_HANDOFF_SHOW_LINE;
|
|
195
|
-
else process.env.SWITCHROOM_HANDOFF_SHOW_LINE = prior;
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("defaults to true when unset", () => {
|
|
199
|
-
delete process.env.SWITCHROOM_HANDOFF_SHOW_LINE;
|
|
200
|
-
expect(shouldShowHandoffLine()).toBe(true);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("returns true for 'true'", () => {
|
|
204
|
-
process.env.SWITCHROOM_HANDOFF_SHOW_LINE = "true";
|
|
205
|
-
expect(shouldShowHandoffLine()).toBe(true);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("returns false for 'false' (case-insensitive)", () => {
|
|
209
|
-
process.env.SWITCHROOM_HANDOFF_SHOW_LINE = "FALSE";
|
|
210
|
-
expect(shouldShowHandoffLine()).toBe(false);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("returns true for any other value (safe default)", () => {
|
|
214
|
-
process.env.SWITCHROOM_HANDOFF_SHOW_LINE = "yes";
|
|
215
|
-
expect(shouldShowHandoffLine()).toBe(true);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe("formatHandoffLine", () => {
|
|
220
|
-
it("wraps the topic in italic HTML with the return emoji", () => {
|
|
221
|
-
const line = formatHandoffLine("fixing the bug", "html");
|
|
222
|
-
expect(line).toBe("<i>↩️ Picked up where we left off, fixing the bug</i>\n\n");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("escapes HTML-unsafe chars in the topic", () => {
|
|
226
|
-
const line = formatHandoffLine("<script> & ok", "html");
|
|
227
|
-
expect(line).toContain("<script> & ok");
|
|
228
|
-
expect(line).not.toContain("<script>");
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("produces MarkdownV2 italic with escaped specials", () => {
|
|
232
|
-
const line = formatHandoffLine("a.b (c)", "markdownv2");
|
|
233
|
-
expect(line.startsWith("_")).toBe(true);
|
|
234
|
-
expect(line.endsWith("_\n\n")).toBe(true);
|
|
235
|
-
expect(line).toContain("a\\.b");
|
|
236
|
-
expect(line).toContain("\\(c\\)");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("produces plain text for 'text' format", () => {
|
|
240
|
-
const line = formatHandoffLine("simple", "text");
|
|
241
|
-
expect(line).toBe("↩️ Picked up where we left off, simple\n\n");
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("always ends with a blank-line separator", () => {
|
|
245
|
-
for (const fmt of ["html", "markdownv2", "text"] as const) {
|
|
246
|
-
expect(formatHandoffLine("t", fmt).endsWith("\n\n")).toBe(true);
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// Regression guard: the handoff prefix was an em-dash bypass for the
|
|
251
|
-
// v0.13.20 voice scrubber (the framework prefix is concatenated AFTER
|
|
252
|
-
// scrubVoice runs in executeReply). Replacing the em-dash with a
|
|
253
|
-
// comma at the template source closes that leak. Pin it so a future
|
|
254
|
-
// operator who "fixes typography" doesn't re-introduce the dash.
|
|
255
|
-
it("does NOT contain an em-dash or en-dash in any format (voice-scrub guard)", () => {
|
|
256
|
-
for (const fmt of ["html", "markdownv2", "text"] as const) {
|
|
257
|
-
const line = formatHandoffLine("anything goes here", fmt);
|
|
258
|
-
expect(line).not.toContain("—");
|
|
259
|
-
expect(line).not.toContain("–");
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
});
|