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";
|
||||
/** Serialize runs for this CLI. */
|
||||
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 = {
|
||||
|
||||
@ -266,6 +266,8 @@ export const CliBackendSchema = z
|
||||
imageArg: z.string().optional(),
|
||||
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
|
||||
serialize: z.boolean().optional(),
|
||||
transcriptDir: z.string().optional(),
|
||||
transcriptPattern: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
||||
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||
import { appendAssistantTranscriptEntry } from "./session-utils.fs.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
import { formatForLog } from "./ws-log.js";
|
||||
|
||||
@ -168,6 +169,22 @@ export function createAgentEventHandler({
|
||||
chatRunState.buffers.delete(clientRunId);
|
||||
chatRunState.deltaSentAt.delete(clientRunId);
|
||||
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 = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
|
||||
@ -3,9 +3,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
appendAssistantTranscriptEntry,
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
describe("readFirstUserMessageFromTranscript", () => {
|
||||
@ -404,3 +406,197 @@ describe("readSessionPreviewItemsFromTranscript", () => {
|
||||
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 os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
@ -32,11 +34,17 @@ export function readSessionMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
export type CliTranscriptConfig = {
|
||||
transcriptDir?: string;
|
||||
transcriptPattern?: string;
|
||||
};
|
||||
|
||||
export function resolveSessionTranscriptCandidates(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile?: string,
|
||||
agentId?: string,
|
||||
cliConfig?: CliTranscriptConfig,
|
||||
): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (sessionFile) candidates.push(sessionFile);
|
||||
@ -47,6 +55,15 @@ export function resolveSessionTranscriptCandidates(
|
||||
if (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`));
|
||||
return candidates;
|
||||
}
|
||||
@ -58,6 +75,101 @@ export function archiveFileOnDisk(filePath: string, reason: string): string {
|
||||
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 {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
@ -34,6 +34,7 @@ import type {
|
||||
} from "./session-utils.types.js";
|
||||
|
||||
export {
|
||||
appendAssistantTranscriptEntry,
|
||||
archiveFileOnDisk,
|
||||
capArrayByJsonBytes,
|
||||
readFirstUserMessageFromTranscript,
|
||||
@ -42,6 +43,7 @@ export {
|
||||
readSessionMessages,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
export type { CliTranscriptConfig, TranscriptAppendResult } from "./session-utils.fs.js";
|
||||
export type {
|
||||
GatewayAgentRow,
|
||||
GatewaySessionRow,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user