fix: write gateway transcripts for CLI provider runs
When using CLI model providers (e.g., claude-cli), the gateway was not writing its own transcript files. This caused chat.history to return empty results since it looked for transcripts in the gateway location. Changes: - Add transcript writing in emitChatFinal for CLI provider runs - Add appendAssistantTranscriptEntry utility function - Add transcriptDir/transcriptPattern config options for CLI backends - Update resolveSessionTranscriptCandidates to check CLI-configured paths - Add tests for new functionality Fixes #3723 Co-Authored-By: maxmaxrouge <maxmaxrouge@users.noreply.github.com>
This commit is contained in:
parent
699784dbee
commit
7b65fc7d36
@ -89,6 +89,10 @@ export type CliBackendConfig = {
|
|||||||
imageMode?: "repeat" | "list";
|
imageMode?: "repeat" | "list";
|
||||||
/** Serialize runs for this CLI. */
|
/** Serialize runs for this CLI. */
|
||||||
serialize?: boolean;
|
serialize?: boolean;
|
||||||
|
/** Directory where this CLI stores session transcripts (for reading history). */
|
||||||
|
transcriptDir?: string;
|
||||||
|
/** Pattern for transcript file names (use {sessionId} placeholder, defaults to {sessionId}.jsonl). */
|
||||||
|
transcriptPattern?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentDefaultsConfig = {
|
export type AgentDefaultsConfig = {
|
||||||
|
|||||||
@ -266,6 +266,8 @@ export const CliBackendSchema = z
|
|||||||
imageArg: z.string().optional(),
|
imageArg: z.string().optional(),
|
||||||
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
|
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
|
||||||
serialize: z.boolean().optional(),
|
serialize: z.boolean().optional(),
|
||||||
|
transcriptDir: z.string().optional(),
|
||||||
|
transcriptPattern: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
||||||
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||||
|
import { appendAssistantTranscriptEntry } from "./session-utils.fs.js";
|
||||||
import { loadSessionEntry } from "./session-utils.js";
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
@ -168,6 +169,22 @@ export function createAgentEventHandler({
|
|||||||
chatRunState.buffers.delete(clientRunId);
|
chatRunState.buffers.delete(clientRunId);
|
||||||
chatRunState.deltaSentAt.delete(clientRunId);
|
chatRunState.deltaSentAt.delete(clientRunId);
|
||||||
if (jobState === "done") {
|
if (jobState === "done") {
|
||||||
|
// Write transcript for CLI provider runs (which don't write their own gateway transcripts)
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const { storePath, entry } = loadSessionEntry(sessionKey);
|
||||||
|
const sessionId = entry?.sessionId ?? clientRunId;
|
||||||
|
appendAssistantTranscriptEntry({
|
||||||
|
message: text,
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
sessionFile: entry?.sessionFile,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Transcript write failure shouldn't block the response
|
||||||
|
}
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: clientRunId,
|
runId: clientRunId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
appendAssistantTranscriptEntry,
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
readSessionPreviewItemsFromTranscript,
|
readSessionPreviewItemsFromTranscript,
|
||||||
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
describe("readFirstUserMessageFromTranscript", () => {
|
describe("readFirstUserMessageFromTranscript", () => {
|
||||||
@ -404,3 +406,197 @@ describe("readSessionPreviewItemsFromTranscript", () => {
|
|||||||
expect(result[0]?.text.endsWith("...")).toBe(true);
|
expect(result[0]?.text.endsWith("...")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("appendAssistantTranscriptEntry", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-append-test-"));
|
||||||
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates transcript file when createIfMissing is true", () => {
|
||||||
|
const sessionId = "new-session-1";
|
||||||
|
const result = appendAssistantTranscriptEntry({
|
||||||
|
message: "Hello from assistant",
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.messageId).toBeDefined();
|
||||||
|
expect(result.message).toBeDefined();
|
||||||
|
expect(result.message?.role).toBe("assistant");
|
||||||
|
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
expect(fs.existsSync(transcriptPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(transcriptPath, "utf-8");
|
||||||
|
const lines = content.split("\n").filter(Boolean);
|
||||||
|
expect(lines.length).toBe(2); // header + message
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when transcript does not exist and createIfMissing is false", () => {
|
||||||
|
const sessionId = "nonexistent-session";
|
||||||
|
const result = appendAssistantTranscriptEntry({
|
||||||
|
message: "Hello",
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
createIfMissing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appends to existing transcript file", () => {
|
||||||
|
const sessionId = "existing-session";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const header = JSON.stringify({ type: "session", version: 1, id: sessionId });
|
||||||
|
fs.writeFileSync(transcriptPath, `${header}\n`, "utf-8");
|
||||||
|
|
||||||
|
const result = appendAssistantTranscriptEntry({
|
||||||
|
message: "New assistant message",
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
createIfMissing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(transcriptPath, "utf-8");
|
||||||
|
const lines = content.split("\n").filter(Boolean);
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
|
||||||
|
const lastLine = JSON.parse(lines[1]);
|
||||||
|
expect(lastLine.message.role).toBe("assistant");
|
||||||
|
expect(lastLine.message.content[0].text).toBe("New assistant message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes label prefix when provided", () => {
|
||||||
|
const sessionId = "labeled-session";
|
||||||
|
const result = appendAssistantTranscriptEntry({
|
||||||
|
message: "Message content",
|
||||||
|
label: "webchat",
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.message?.content[0].text).toContain("[webchat]");
|
||||||
|
expect(result.message?.content[0].text).toContain("Message content");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns error when storePath is undefined and no sessionFile", () => {
|
||||||
|
const result = appendAssistantTranscriptEntry({
|
||||||
|
message: "Hello",
|
||||||
|
sessionId: "test",
|
||||||
|
storePath: undefined,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("not resolved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSessionTranscriptCandidates with CLI config", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-candidates-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes CLI transcript path when cliConfig is provided", () => {
|
||||||
|
const sessionId = "cli-session";
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
transcriptDir: tmpDir,
|
||||||
|
transcriptPattern: "{sessionId}.jsonl",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidates).toContain(path.join(tmpDir, `${sessionId}.jsonl`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses default pattern when transcriptPattern is not provided", () => {
|
||||||
|
const sessionId = "default-pattern-session";
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
transcriptDir: tmpDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidates).toContain(path.join(tmpDir, `${sessionId}.jsonl`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands tilde in transcriptDir", () => {
|
||||||
|
const sessionId = "tilde-session";
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
transcriptDir: "~/.test-cli-logs",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedPath = path.join(homeDir, ".test-cli-logs", `${sessionId}.jsonl`);
|
||||||
|
expect(candidates).toContain(expectedPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not add CLI path when cliConfig is undefined", () => {
|
||||||
|
const sessionId = "no-cli-session";
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
|
||||||
|
|
||||||
|
// Should only have storePath-derived path and default clawdbot path
|
||||||
|
expect(candidates.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports custom transcript pattern", () => {
|
||||||
|
const sessionId = "custom-pattern";
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
transcriptDir: tmpDir,
|
||||||
|
transcriptPattern: "logs-{sessionId}.json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidates).toContain(path.join(tmpDir, `logs-${sessionId}.json`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
||||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||||
import { stripEnvelope } from "./chat-sanitize.js";
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||||
@ -32,11 +34,17 @@ export function readSessionMessages(
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CliTranscriptConfig = {
|
||||||
|
transcriptDir?: string;
|
||||||
|
transcriptPattern?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveSessionTranscriptCandidates(
|
export function resolveSessionTranscriptCandidates(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
|
cliConfig?: CliTranscriptConfig,
|
||||||
): string[] {
|
): string[] {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (sessionFile) candidates.push(sessionFile);
|
if (sessionFile) candidates.push(sessionFile);
|
||||||
@ -47,6 +55,15 @@ export function resolveSessionTranscriptCandidates(
|
|||||||
if (agentId) {
|
if (agentId) {
|
||||||
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
||||||
}
|
}
|
||||||
|
// Check CLI-configured transcript location if provided
|
||||||
|
if (cliConfig?.transcriptDir) {
|
||||||
|
const pattern = cliConfig.transcriptPattern ?? "{sessionId}.jsonl";
|
||||||
|
const fileName = pattern.replace("{sessionId}", sessionId);
|
||||||
|
const expandedDir = cliConfig.transcriptDir.startsWith("~")
|
||||||
|
? path.join(os.homedir(), cliConfig.transcriptDir.slice(1))
|
||||||
|
: cliConfig.transcriptDir;
|
||||||
|
candidates.push(path.join(expandedDir, fileName));
|
||||||
|
}
|
||||||
candidates.push(path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`));
|
candidates.push(path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`));
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
@ -58,6 +75,101 @@ export function archiveFileOnDisk(filePath: string, reason: string): string {
|
|||||||
return archived;
|
return archived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TranscriptAppendResult = {
|
||||||
|
ok: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
message?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveTranscriptWritePath(params: {
|
||||||
|
sessionId: string;
|
||||||
|
storePath: string | undefined;
|
||||||
|
sessionFile?: string;
|
||||||
|
}): string | null {
|
||||||
|
const { sessionId, storePath, sessionFile } = params;
|
||||||
|
if (sessionFile) return sessionFile;
|
||||||
|
if (!storePath) return null;
|
||||||
|
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (fs.existsSync(params.transcriptPath)) return { ok: true };
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
|
||||||
|
const header = {
|
||||||
|
type: "session",
|
||||||
|
version: CURRENT_SESSION_VERSION,
|
||||||
|
id: params.sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendAssistantTranscriptEntry(params: {
|
||||||
|
message: string;
|
||||||
|
label?: string;
|
||||||
|
sessionId: string;
|
||||||
|
storePath: string | undefined;
|
||||||
|
sessionFile?: string;
|
||||||
|
createIfMissing?: boolean;
|
||||||
|
}): TranscriptAppendResult {
|
||||||
|
const transcriptPath = resolveTranscriptWritePath({
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
storePath: params.storePath,
|
||||||
|
sessionFile: params.sessionFile,
|
||||||
|
});
|
||||||
|
if (!transcriptPath) {
|
||||||
|
return { ok: false, error: "transcript path not resolved" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(transcriptPath)) {
|
||||||
|
if (!params.createIfMissing) {
|
||||||
|
return { ok: false, error: "transcript file not found" };
|
||||||
|
}
|
||||||
|
const ensured = ensureTranscriptFile({
|
||||||
|
transcriptPath,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
});
|
||||||
|
if (!ensured.ok) {
|
||||||
|
return { ok: false, error: ensured.error ?? "failed to create transcript file" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const messageId = randomUUID().slice(0, 8);
|
||||||
|
const labelPrefix = params.label ? `[${params.label}]\n\n` : "";
|
||||||
|
const messageBody: Record<string, unknown> = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: `${labelPrefix}${params.message}` }],
|
||||||
|
timestamp: now,
|
||||||
|
stopReason: "injected",
|
||||||
|
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||||
|
};
|
||||||
|
const transcriptEntry = {
|
||||||
|
type: "message",
|
||||||
|
id: messageId,
|
||||||
|
timestamp: new Date(now).toISOString(),
|
||||||
|
message: messageBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, messageId, message: transcriptEntry.message };
|
||||||
|
}
|
||||||
|
|
||||||
function jsonUtf8Bytes(value: unknown): number {
|
function jsonUtf8Bytes(value: unknown): number {
|
||||||
try {
|
try {
|
||||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import type {
|
|||||||
} from "./session-utils.types.js";
|
} from "./session-utils.types.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
appendAssistantTranscriptEntry,
|
||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
@ -42,6 +43,7 @@ export {
|
|||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
export type { CliTranscriptConfig, TranscriptAppendResult } from "./session-utils.fs.js";
|
||||||
export type {
|
export type {
|
||||||
GatewayAgentRow,
|
GatewayAgentRow,
|
||||||
GatewaySessionRow,
|
GatewaySessionRow,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user