fix: persist user code agent sessions

This commit is contained in:
Codex
2026-05-31 15:59:35 +08:00
parent 7bad130e5d
commit 945c2ba3d0
10 changed files with 874 additions and 37 deletions
+87
View File
@@ -288,6 +288,76 @@ test("cloud api access control grants visible device pods and requires device-po
}
});
test("cloud api stores and restores Code Agent conversations by authenticated account", async () => {
const server = createCloudApiServer({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
},
now: () => "2026-05-31T07:30:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const adminCookie = adminLogin.cookie;
const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminCookie);
const bobCreate = await postJson(port, "/v1/admin/users", { username: "bob", password: "bob-pass" }, adminCookie);
assert.equal(aliceCreate.status, 201);
assert.equal(bobCreate.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" });
const stored = await putJson(port, "/v1/agent/conversations/cnv_account_sync", {
projectId: "prj_device_pod_workbench",
sessionId: "ses_account_sync",
threadId: "thread-account-sync",
lastTraceId: "trc_account_sync",
sessionStatus: "idle",
messages: [
{ id: "msg_user", role: "user", title: "用户", text: "在吗", status: "sent", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" },
{ id: "msg_agent", role: "agent", title: "Agent", text: "在", status: "completed", traceId: "trc_account_sync", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" }
]
}, aliceLogin.cookie);
assert.equal(stored.status, 200);
assert.equal(stored.body.conversation.conversationId, "cnv_account_sync");
assert.equal(stored.body.conversation.sessionId, "ses_account_sync");
assert.equal(stored.body.conversation.threadId, "thread-account-sync");
assert.equal(stored.body.conversation.status, "idle");
assert.equal(stored.body.conversation.messages.length, 2);
const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie);
assert.equal(aliceList.status, 200);
assert.equal(aliceList.body.count, 1);
assert.equal(aliceList.body.defaultConversation.conversationId, "cnv_account_sync");
assert.equal(aliceList.body.defaultConversation.messages[1].text, "在");
assert.equal(aliceList.body.defaultConversation.valuesRedacted, true);
const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", bobLogin.cookie);
assert.equal(bobList.status, 200);
assert.deepEqual(bobList.body.conversations, []);
const bobDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", bobLogin.cookie);
assert.equal(bobDirect.status, 404);
assert.equal(bobDirect.body.error.code, "agent_conversation_not_found");
const logout = await postJson(port, "/auth/logout", {}, aliceLogin.cookie);
assert.equal(logout.status, 200);
const relogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
const restored = await getJson(port, "/v1/agent/conversations/cnv_account_sync", relogin.cookie);
assert.equal(restored.status, 200);
assert.equal(restored.body.conversation.threadId, "thread-account-sync");
assert.equal(restored.body.conversation.messages[0].text, "在吗");
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api protects device-pod routes when access control is required", async () => {
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
@@ -988,6 +1058,23 @@ function devicePodInternalHeaders(extra = {}) {
return { ...extra, "x-hwlab-internal-service": "hwlab-device-pod", "x-hwlab-internal-token": INTERNAL_TOKEN };
}
async function putJson(port, path, body, cookie = null, extraHeaders = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: "PUT",
headers: {
"content-type": "application/json",
...(cookie ? { cookie } : {}),
...extraHeaders
},
body: JSON.stringify(body)
});
return {
status: response.status,
body: await response.json(),
cookie: response.headers.get("set-cookie")
};
}
async function getJson(port, path, cookie = null) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
headers: cookie ? { cookie } : {}
+199 -1
View File
@@ -523,6 +523,77 @@ class AccessController {
}) ?? null;
}
async listAgentConversations(request, response, url) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
const projectId = textOr(url.searchParams.get("projectId"), "");
const limit = boundedListLimit(url.searchParams.get("limit"));
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
ownerScoped: true,
projectId,
limit,
now: this.now()
}) ?? [];
const conversations = conversationsFromAgentSessions(sessions);
return sendJson(response, 200, {
ok: true,
status: "succeeded",
contractVersion: "agent-conversations-v1",
actor: publicActor(auth.actor),
projectId: projectId || null,
conversations,
defaultConversation: conversations[0] ?? null,
count: conversations.length
});
}
async getAgentConversation(request, response, conversationId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeConversationIdLocal(conversationId)) {
return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
}
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
ownerScoped: true,
conversationId,
limit: 20,
now: this.now()
}) ?? [];
const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation });
}
async updateAgentConversation(request, response, conversationId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeConversationIdLocal(conversationId)) {
return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
}
const body = await jsonBody(request);
const sessionId = safeAgentSessionId(body.sessionId) || safeAgentSessionId(body.session?.sessionId) || `ses_${conversationId.slice(4)}`;
const session = await this.recordAgentSessionOwner({
ownerUserId: auth.actor.id,
sessionId,
projectId: textOr(body.projectId, "prj_device_pod_workbench"),
agentId: textOr(body.agentId, "hwlab-code-agent"),
status: textOr(body.status ?? body.sessionStatus, "active"),
conversationId,
threadId: textOr(body.threadId, null),
traceId: textOr(body.lastTraceId ?? body.traceId, null),
session: normalizeConversationSnapshot(body, auth.actor)
});
const conversation = conversationsFromAgentSessions(session ? [session] : []).find((item) => item.conversationId === conversationId) ?? null;
return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation });
}
async requireGrantTargets({ devicePodId, userId }) {
const pod = await this.store.getDevicePod(devicePodId);
if (!pod || pod.status !== "active") {
@@ -1097,6 +1168,19 @@ class MemoryAccessStore {
return session;
}
async getAgentSession(id) { return this.agentSessions.get(id) ?? null; }
async listAgentSessionsForUser(input = {}) {
const ownerUserId = textOr(input.ownerUserId, "");
const role = textOr(input.actorRole, "user");
const projectId = textOr(input.projectId, "");
const conversationId = textOr(input.conversationId, "");
const limit = boundedListLimit(input.limit);
return [...this.agentSessions.values()]
.filter((session) => input.ownerScoped === true || role !== "admin" ? session.ownerUserId === ownerUserId : true)
.filter((session) => !projectId || session.projectId === projectId)
.filter((session) => !conversationId || session.conversationId === conversationId)
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))
.slice(0, limit);
}
}
class PostgresAccessStore extends MemoryAccessStore {
@@ -1174,10 +1258,38 @@ class PostgresAccessStore extends MemoryAccessStore {
async recordAgentSessionOwner(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
const session = normalizeAgentSessionOwnerRecord(input, null, now);
const existing = input.sessionId ? await this.getAgentSession(input.sessionId) : null;
const session = normalizeAgentSessionOwnerRecord(input, existing, now);
const result = await this.query("INSERT INTO agent_sessions (id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (id) DO UPDATE SET project_id = COALESCE(EXCLUDED.project_id, agent_sessions.project_id), agent_id = EXCLUDED.agent_id, status = EXCLUDED.status, ended_at = EXCLUDED.ended_at, owner_user_id = EXCLUDED.owner_user_id, conversation_id = COALESCE(EXCLUDED.conversation_id, agent_sessions.conversation_id), thread_id = COALESCE(EXCLUDED.thread_id, agent_sessions.thread_id), last_trace_id = COALESCE(EXCLUDED.last_trace_id, agent_sessions.last_trace_id), session_json = EXCLUDED.session_json, updated_at = EXCLUDED.updated_at RETURNING id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at", [session.id, session.projectId, session.agentId, session.status, session.startedAt, session.endedAt, session.ownerUserId, session.conversationId, session.threadId, session.lastTraceId, stableJson(session.session ?? {}), session.updatedAt]);
return pgAgentSession(result.rows?.[0]);
}
async getAgentSession(id) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE id = $1 LIMIT 1", [id]); return pgAgentSession(result.rows?.[0]); }
async listAgentSessionsForUser(input = {}) {
await this.ensureSchema();
const role = textOr(input.actorRole, "user");
const ownerUserId = textOr(input.ownerUserId, "");
const projectId = textOr(input.projectId, "");
const conversationId = textOr(input.conversationId, "");
const limit = boundedListLimit(input.limit);
const clauses = [];
const params = [];
if (input.ownerScoped === true || role !== "admin") {
params.push(ownerUserId);
clauses.push(`owner_user_id = $${params.length}`);
}
if (projectId) {
params.push(projectId);
clauses.push(`project_id = $${params.length}`);
}
if (conversationId) {
params.push(conversationId);
clauses.push(`conversation_id = $${params.length}`);
}
params.push(limit);
const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST LIMIT $${params.length}`;
const result = await this.query(sql, params);
return result.rows.map(pgAgentSession);
}
}
function parseDevicePodPath(pathname) {
@@ -1295,6 +1407,92 @@ function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00",
function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; }
function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: input.sessionId, projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent", agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent", status: input.status ?? existing?.status ?? "active", startedAt: existing?.startedAt ?? input.startedAt ?? now, endedAt: input.endedAt ?? existing?.endedAt ?? null, ownerUserId: input.ownerUserId, conversationId: input.conversationId ?? existing?.conversationId ?? null, threadId: input.threadId ?? existing?.threadId ?? null, lastTraceId: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: normalizeObject(input.session ?? existing?.session), updatedAt: now }; }
function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; }
function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); }
function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); }
function normalizeConversationSnapshot(body = {}, actor = null) {
const snapshot = normalizeObject(body.snapshot ?? body);
const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages;
const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages;
return {
...snapshot,
messages: Array.isArray(messagesSource) ? messagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : [],
chatMessages: Array.isArray(chatMessagesSource) ? chatMessagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
};
}
function redactConversationMessage(message) {
if (!message || typeof message !== "object") return null;
const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;
return pruneEmpty({
id: textOr(message.id, ""),
role: textOr(message.role, ""),
title: textOr(message.title, ""),
text: boundedText(message.text, 12000),
status: textOr(message.status, ""),
traceId: textOr(message.traceId, ""),
conversationId: textOr(message.conversationId, ""),
sessionId: textOr(message.sessionId, ""),
threadId: textOr(message.threadId, ""),
retryOf: textOr(message.retryOf, ""),
createdAt: textOr(message.createdAt, ""),
updatedAt: textOr(message.updatedAt, ""),
runnerTrace: runnerTrace ? pruneEmpty({
traceId: runnerTrace.traceId,
status: runnerTrace.status,
sessionId: runnerTrace.sessionId,
threadId: runnerTrace.threadId,
eventCount: runnerTrace.eventCount,
eventsCompacted: runnerTrace.eventsCompacted === true,
fullTraceLoaded: runnerTrace.fullTraceLoaded === true,
lastEvent: runnerTrace.lastEvent
}) : undefined
});
}
function conversationsFromAgentSessions(sessions = []) {
const byConversation = new Map();
for (const session of sessions) {
const conversationId = textOr(session.conversationId, "");
if (!conversationId) continue;
const previous = byConversation.get(conversationId);
if (previous && String(previous.updatedAt ?? "") >= String(session.updatedAt ?? "")) continue;
byConversation.set(conversationId, publicAgentConversation(session));
}
return [...byConversation.values()].sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
}
function publicAgentConversation(session) {
const snapshot = normalizeObject(session.session);
const messages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : [];
return {
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
status: session.status,
projectId: session.projectId,
agentId: session.agentId,
ownerUserId: session.ownerUserId,
lastTraceId: session.lastTraceId,
updatedAt: session.updatedAt,
startedAt: session.startedAt,
endedAt: session.endedAt,
session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status: session.status }),
messages,
snapshot: pruneEmpty({
sessionStatus: snapshot.sessionStatus,
source: snapshot.source,
updatedAt: snapshot.updatedAt,
valuesRedacted: snapshot.valuesRedacted !== false
}),
valuesRedacted: true
};
}
function boundedText(value, maxBytes) {
const text = String(value ?? "");
const buffer = Buffer.from(text, "utf8");
return buffer.length > maxBytes ? buffer.subarray(0, maxBytes).toString("utf8") : text;
}
function pruneEmpty(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); }
function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); }
function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); }
+3 -1
View File
@@ -125,6 +125,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
message,
conversationId,
sessionId: requestedSessionId,
threadId: params.threadId,
traceId,
env: options.env ?? process.env,
now: options.now,
@@ -839,13 +840,14 @@ function notRequestedSkills() {
};
}
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
async function callCodexStdioRunner({ message, conversationId, sessionId, threadId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
try {
return await manager.chat({
message,
conversationId,
sessionId,
threadId,
traceId,
env,
now,
@@ -1230,6 +1230,134 @@ function createPartialAssistantNoTurnCompletedClient({ calls, text = "partial as
};
}
test("Codex stdio resumes an idle-expired thread instead of returning no-event timeout", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
const codexHome = await prepareFakeCodexHome();
const manager = createCodexStdioSessionManager({
idleTimeoutMs: 10,
idFactory: () => "ses_stdio_idle_resume",
createRpcClient: async () => createFakeAppServerClient({
calls,
responses: ["first response", "resumed response"]
})
});
const env = {
PATH: process.env.PATH,
OPENAI_API_KEY: "test-openai-key-material",
CODEX_HOME: codexHome,
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses"
};
try {
const first = await handleCodeAgentChat(
{
conversationId: "cnv_stdio_idle_resume",
traceId: "trc_stdio_idle_resume_first",
message: "第一轮"
},
{
now: () => "2026-05-31T00:00:00.000Z",
env,
codexStdioManager: manager
}
);
validateCodeAgentChatSchema(first);
assert.equal(first.status, "completed");
assert.equal(first.session.threadId, "thread_stdio_ready");
const second = await handleCodeAgentChat(
{
conversationId: "cnv_stdio_idle_resume",
sessionId: first.sessionId,
threadId: first.session.threadId,
traceId: "trc_stdio_idle_resume_second",
message: "一小时后继续"
},
{
now: () => "2026-05-31T01:00:00.000Z",
env,
codexStdioManager: manager
}
);
validateCodeAgentChatSchema(second);
assert.equal(second.status, "completed");
assert.equal(second.error, undefined);
assert.equal(second.session.sessionId, "ses_stdio_idle_resume");
assert.equal(second.session.threadId, "thread_stdio_ready");
assert.equal(second.session.turn, 2);
assert.equal(second.sessionReuse.reused, true);
assert.deepEqual(calls.filter((call) => call.method === "thread/start").map((call) => call.args.serviceName), ["hwlab-cloud-api"]);
assert.deepEqual(calls.filter((call) => call.method === "thread/resume").map((call) => call.args.threadId), ["thread_stdio_ready"]);
assert.equal(calls.filter((call) => call.method === "turn/start").length, 2);
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
test("Codex stdio uses a persisted thread id after manager restart", async () => {
const calls = [];
const fakeCodex = await createFakeCodexCommand();
const codexHome = await prepareFakeCodexHome();
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_should_not_replace_persisted",
createRpcClient: async () => createFakeAppServerClient({
calls,
responses: ["resumed after restart"]
})
});
const env = {
PATH: process.env.PATH,
OPENAI_API_KEY: "test-openai-key-material",
CODEX_HOME: codexHome,
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd(),
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses"
};
try {
const payload = await handleCodeAgentChat(
{
conversationId: "cnv_stdio_restart_resume",
sessionId: "ses_stdio_restart_resume",
threadId: "thread_persisted_account",
traceId: "trc_stdio_restart_resume",
message: "恢复上次账号会话"
},
{
now: () => "2026-05-31T01:05:00.000Z",
env,
codexStdioManager: manager
}
);
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.session.sessionId, "ses_stdio_restart_resume");
assert.equal(payload.session.threadId, "thread_persisted_account");
assert.deepEqual(calls.filter((call) => call.method === "thread/start"), []);
assert.deepEqual(calls.filter((call) => call.method === "thread/resume").map((call) => call.args.threadId), ["thread_persisted_account"]);
assert.equal(calls.filter((call) => call.method === "turn/start")[0].args.threadId, "thread_persisted_account");
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => {
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_stdio_blocked"
+27 -12
View File
@@ -488,6 +488,7 @@ export function createCodexStdioSessionManager(options = {}) {
const acquire = await acquireSession({
conversationId,
requestedSessionId: params.sessionId,
threadId: params.threadId,
traceId,
workspace,
sandbox,
@@ -1109,6 +1110,7 @@ export function createCodexStdioSessionManager(options = {}) {
const timestampMs = Date.parse(timestamp);
const conversationId = requiredId(params.conversationId, "cnv");
const requestedSessionId = optionalId(params.requestedSessionId);
const requestedThreadId = optionalId(params.threadId);
const mappedSessionId = conversations.get(conversationId) ?? null;
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
@@ -1127,17 +1129,28 @@ export function createCodexStdioSessionManager(options = {}) {
let reused = Boolean(session);
if (session && sessionExpired(session, timestampMs)) {
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: "session_expired",
summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
session,
timestamp,
traceId: params.traceId
});
const resumeThreadId = requestedThreadId ?? optionalId(session.threadId);
if (["idle", "expired"].includes(session.status) && resumeThreadId) {
session.status = "idle";
session.threadId = resumeThreadId;
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.currentTraceId = null;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.statusReason = "idle_timeout_resume";
} else {
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: "session_expired",
summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
session,
timestamp,
traceId: params.traceId
});
}
}
if (session?.status === "busy") {
@@ -1185,7 +1198,7 @@ export function createCodexStdioSessionManager(options = {}) {
lastTraceId: optionalId(params.traceId),
currentTraceId: null,
turn: 0,
threadId: null,
threadId: requestedThreadId,
durable: true,
longLivedSession: true,
codexStdio: true,
@@ -1193,9 +1206,11 @@ export function createCodexStdioSessionManager(options = {}) {
secretMaterialStored: false
};
sessions.set(effectiveSessionId, session);
reused = Boolean(requestedThreadId);
}
session.conversationIds.add(conversationId);
session.threadId = requestedThreadId ?? session.threadId;
session.workspace = params.workspace ?? session.workspace;
session.sandbox = params.sandbox ?? session.sandbox;
session.status = "busy";
+2
View File
@@ -583,6 +583,7 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options
ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole ?? null,
sessionId,
projectId: params.projectId ?? payload.projectId ?? null,
conversationId,
threadId,
traceId,
@@ -618,6 +619,7 @@ function codeAgentSessionOwnerEvidence(payload = {}, params = {}) {
sessionId: payload.sessionId ?? payload.session?.sessionId ?? payload.sessionReuse?.sessionId ?? params.sessionId ?? null,
threadId: payload.session?.threadId ?? payload.sessionReuse?.threadId ?? payload.threadId ?? params.threadId ?? null,
traceId: payload.traceId ?? params.traceId ?? null,
projectId: payload.projectId ?? params.projectId ?? null,
secretMaterialStored: false,
valuesRedacted: true
};
+16
View File
@@ -314,6 +314,22 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/agent/conversations" && request.method === "GET") {
await options.accessController.listAgentConversations(request, response, url);
return;
}
const agentConversationMatch = url.pathname.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
if (agentConversationMatch && request.method === "GET") {
await options.accessController.getAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1]));
return;
}
if (agentConversationMatch && (request.method === "PUT" || request.method === "PATCH")) {
await options.accessController.updateAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1]));
return;
}
if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") {
await options.accessController.handleAccessStatusRoute(request, response, url);
return;
+131
View File
@@ -163,6 +163,137 @@ test("hwlab-cli client agent trace shows assistant stream text in compact output
assert.equal(result.payload.body.eventsCount, 1);
});
test("hwlab-cli client agent trace auto logs in when protected trace has no local session", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-"));
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"trace",
"trc_protected",
"--base-url",
"http://web.test",
"--username",
"admin"
], {
cwd,
env: { HWLAB_PASSWORD: "secret-password" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/auth/login")) {
return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), {
status: 200,
headers: { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" }
});
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_protected", events: [] }), { status: 200 });
},
now: () => "2026-05-31T07:20:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.deepEqual(calls.map((call) => call.url), [
"http://web.test/auth/login",
"http://web.test/v1/agent/chat/trace/trc_protected"
]);
assert.equal(calls[1].init.headers.cookie, "hwlab_session=session-auto");
assert.equal(JSON.stringify(result.payload).includes("secret-password"), false);
const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8"));
assert.equal(session.cookie, "hwlab_session=session-auto");
});
test("hwlab-cli client agent result refreshes expired session after 401 and reports compact result", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-refresh-"));
await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "old"], {
cwd,
fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-old; Path=/" } })
});
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"result",
"trc_refresh",
"--base-url",
"http://web.test",
"--username",
"admin"
], {
cwd,
env: { HWLAB_PASSWORD: "new-password" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/auth/login")) {
return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), {
status: 200,
headers: { "set-cookie": "hwlab_session=session-new; Path=/" }
});
}
if (init?.headers?.cookie === "hwlab_session=session-old") {
return new Response(JSON.stringify({ error: { code: "auth_required" } }), { status: 401 });
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_refresh", reply: { role: "assistant", content: "done" } }), { status: 200 });
},
sleep: async () => {},
now: () => "2026-05-31T07:21:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.deepEqual(calls.map((call) => call.url), [
"http://web.test/v1/agent/chat/result/trc_refresh",
"http://web.test/auth/login",
"http://web.test/v1/agent/chat/result/trc_refresh"
]);
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-old");
assert.equal(calls[2].init.headers.cookie, "hwlab_session=session-new");
assert.equal(result.payload.action, "client.agent.result");
assert.equal(result.payload.body.assistantText, "done");
});
test("hwlab-cli client protected agent commands return structured auth diagnosis for forbidden trace", async () => {
const result = await runHwlabCli(["client", "agent", "trace", "trc_forbidden", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({ error: { code: "agent_session_owner_required" } }), { status: 403 })
});
assert.equal(result.exitCode, 1);
assert.equal(result.payload.httpStatus, 403);
assert.equal(result.payload.authDiagnosis.code, "auth_forbidden");
assert.equal(result.payload.authDiagnosis.cookieSource, "explicit");
assert.match(result.payload.authDiagnosis.message, /访/u);
});
test("hwlab-cli client agent inspect builds protected inspect query", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"inspect",
"--trace-id",
"trc_inspect",
"--conversation-id",
"cnv_inspect",
"--session-id",
"ses_inspect",
"--thread-id",
"thread-1",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ ok: true, status: "found", latestTraceId: "trc_inspect" }), { status: 200 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_inspect&conversationId=cnv_inspect&sessionId=ses_inspect&threadId=thread-1");
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a");
assert.equal(result.payload.action, "client.agent.inspect");
assert.equal(result.payload.body.status, "found");
});
test("hwlab-cli client harness submits waits and audits trace friction", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
+156 -17
View File
@@ -1,4 +1,4 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
const VERSION = "0.2.0-client";
@@ -84,7 +84,9 @@ function help() {
"hwlab-cli client request GET /v1/access/status [--full]",
"hwlab-cli client rpc system.health [--full]",
"hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent result TRACE_ID",
"hwlab-cli client agent trace TRACE_ID",
"hwlab-cli client agent inspect --trace-id TRACE_ID",
"hwlab-cli client agent cancel TRACE_ID",
"hwlab-cli client harness submit --message TEXT --provider-profile deepseek",
"hwlab-cli client harness wait TRACE_ID --timeout-ms 55000",
@@ -267,12 +269,23 @@ async function devicePodProbe(context: any, podId: string) {
async function agentCommand(context: any) {
const subcommand = context.rest[0] || "send";
if (subcommand === "send") return agentSend(context);
if (subcommand === "result") {
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.result", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "trace") {
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId);
const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "inspect") {
const pathName = agentInspectPath(context);
const response = await requestJson({ ...context, method: "GET", path: pathName });
return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) });
}
if (subcommand === "cancel") {
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId) }), extraHeaders: { "x-trace-id": traceId } });
@@ -281,6 +294,22 @@ async function agentCommand(context: any) {
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
}
function agentInspectPath(context: any) {
const params = new URLSearchParams();
const traceId = text(context.rest[1] ?? context.parsed.traceId);
const conversationId = text(context.parsed.conversationId);
const sessionId = text(context.parsed.sessionId);
const threadId = text(context.parsed.threadId);
if (traceId) params.set("traceId", requiredTraceId(traceId));
if (conversationId) params.set("conversationId", conversationId);
if (sessionId) params.set("sessionId", sessionId);
if (threadId) params.set("threadId", threadId);
if ([...params.keys()].length === 0) {
throw cliError("missing_inspect_query", "client agent inspect requires --trace-id, --conversation-id, --session-id, or --thread-id");
}
return `/v1/agent/chat/inspect?${params.toString()}`;
}
async function harnessCommand(context: any) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand)) return harnessHelp();
@@ -647,26 +676,91 @@ function normalizeRequestPath(value: string) {
return pathName;
}
async function requestJson({ parsed, env, fetchImpl, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs }: any) {
async function requestJson(context: any) {
const { parsed, env, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs } = context;
const url = `${baseUrl(parsed, env)}${pathName}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs ?? numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS);
try {
const cookie = auth ? await authCookie({ parsed, env, cwd: cwd ?? process.cwd() }) : null;
const headers = clean({
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}),
...extraHeaders
});
const response = await fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
const textBody = await response.text();
return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName };
const effectiveAuth = auth && parsed.noAuth !== true;
const explicitCookie = explicitAuthCookie(parsed, env);
const session = effectiveAuth && !explicitCookie && parsed.noSession !== true
? await loadSession({ parsed, env, cwd: cwd ?? process.cwd() })
: null;
let cookie = effectiveAuth ? explicitCookie || session?.cookie || null : null;
const authState: any = {
required: effectiveAuth,
cookieSource: cookie ? explicitCookie ? "explicit" : "state" : null,
autoLoginAttempted: false,
autoLoginStatus: null,
retryAfterLogin: false
};
if (effectiveAuth && !cookie && autoAuthAllowed(parsed)) {
const login = await autoLoginSession(context, controller.signal);
authState.autoLoginAttempted = true;
authState.autoLoginStatus = login.status;
authState.cookieSource = login.cookie ? "auto-login" : null;
cookie = login.cookie ?? null;
}
let response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName });
if (effectiveAuth && response.status === 401 && !explicitCookie && autoAuthAllowed(parsed)) {
const login = await autoLoginSession(context, controller.signal, { force: true });
authState.autoLoginAttempted = true;
authState.autoLoginStatus = login.status;
if (login.cookie) {
authState.cookieSource = "auto-login";
authState.retryAfterLogin = true;
cookie = login.cookie;
response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName });
}
}
return { ...response, authDiagnosis: authDiagnosis(response, authState) };
} finally {
clearTimeout(timer);
}
}
async function sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal, pathName }: any) {
const headers = clean({
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}),
...extraHeaders
});
const response = await context.fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal });
const textBody = await response.text();
return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName };
}
async function autoLoginSession(context: any, signal: AbortSignal, { force = false } = {}) {
const password = await optionalPasswordValue(context);
if (!password) return { status: "credentials_missing", cookie: null };
const username = text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin";
const response = await sendJsonRequest({
context,
url: `${baseUrl(context.parsed, context.env)}/auth/login`,
method: "POST",
pathName: "/auth/login",
body: { username, password },
cookie: null,
extraHeaders: {},
signal
});
const cookie = cookieFromResponse(response);
const success = isHttpSuccess(response) && response.body?.authenticated === true && cookie;
if (success) {
await saveSession(context, {
baseUrl: baseUrl(context.parsed, context.env),
cookie,
user: safeUser(response.body?.user ?? response.body?.actor),
expiresAt: textOrNull(response.body?.expiresAt),
updatedAt: context.now(),
refreshedAfter401: force === true || undefined
});
}
return { status: success ? "succeeded" : `failed_http_${response.status}`, cookie: success ? cookie : null };
}
function parseOptions(argv: string[]): ParsedArgs {
const out: ParsedArgs = { _: [] };
for (let index = 0; index < argv.length; index += 1) {
@@ -684,9 +778,14 @@ function parseOptions(argv: string[]): ParsedArgs {
}
async function passwordValue({ parsed, env, stdinText }: any) {
if (typeof parsed.passwordEnv === "string") return requiredText(env[parsed.passwordEnv], parsed.passwordEnv);
const password = await optionalPasswordValue({ parsed, env, stdinText });
return requiredText(password, "password");
}
async function optionalPasswordValue({ parsed, env, stdinText }: any) {
if (typeof parsed.passwordEnv === "string") return text(env[parsed.passwordEnv]);
if (parsed.passwordStdin === true) return stdinText !== undefined ? stdinText.trimEnd() : await readStdin();
return requiredText(parsed.password ?? env.HWLAB_PASSWORD, "password");
return text(parsed.password ?? env.HWLAB_PASSWORD);
}
async function readStdin() {
@@ -700,13 +799,22 @@ function baseUrl(parsed: ParsedArgs, env: EnvLike) {
}
async function authCookie({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
const explicit = explicitAuthCookie(parsed, env);
if (explicit) return explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}`;
if (parsed.noSession === true) return null;
const session = await loadSession({ parsed, env, cwd });
return session?.cookie ?? null;
}
function explicitAuthCookie(parsed: ParsedArgs, env: EnvLike) {
const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
return explicit ? explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}` : null;
}
function autoAuthAllowed(parsed: ParsedArgs) {
return parsed.noAuth !== true && parsed.noAutoAuth !== true;
}
async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
try {
const raw = await readFile(stateFile(parsed, cwd), "utf8");
@@ -722,7 +830,8 @@ async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvL
async function saveSession(context: any, session: Record<string, unknown>) {
const file = stateFile(context.parsed, context.cwd);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, "utf8");
await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await chmod(file, 0o600).catch(() => {});
}
async function clearSession(context: any) {
@@ -752,11 +861,41 @@ function responsePayload(action: string, response: any, context: any, extra: Rec
baseUrl: baseUrl(context.parsed, context.env),
httpStatus: response.status,
route: extra.route ?? route(response.method, response.path),
...(response.authDiagnosis ? { authDiagnosis: response.authDiagnosis } : {}),
...extra,
body: extra.body ?? response.body
};
}
function authDiagnosis(response: any, authState: any) {
if (!authState?.required) return null;
if (response.status !== 401 && response.status !== 403) return null;
if (response.status === 403) {
return {
code: "auth_forbidden",
status: "forbidden",
message: "当前账号无权访问该受保护资源,可能是 trace/result 不属于当前 owner,或当前用户不是 admin。",
httpStatus: response.status,
cookieSource: authState.cookieSource,
autoLoginAttempted: authState.autoLoginAttempted,
retryAfterLogin: authState.retryAfterLogin
};
}
const missingCredentials = authState.autoLoginAttempted && authState.autoLoginStatus === "credentials_missing";
return {
code: missingCredentials ? "auth_credentials_missing" : "auth_required_or_expired",
status: missingCredentials ? "credentials_missing" : "unauthorized",
message: missingCredentials
? "需要登录后访问该受保护资源;请提供 --password-env、--password、--password-stdin 或 HWLAB_PASSWORD,让 CLI 自动登录。"
: "登录态不存在或已过期;CLI 已按可用凭据尝试自动登录,仍未通过认证。",
httpStatus: response.status,
cookieSource: authState.cookieSource,
autoLoginAttempted: authState.autoLoginAttempted,
autoLoginStatus: authState.autoLoginStatus,
retryAfterLogin: authState.retryAfterLogin
};
}
function compactProbe(method: string, pathName: string, response: any) {
return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactApiBody(response.body) };
}
+125 -6
View File
@@ -244,6 +244,9 @@ const state = {
};
let codeAgentSessionPersistTimer = null;
let accountConversationHydrated = false;
let accountConversationPersistInFlight = false;
let accountConversationPersistPending = false;
restoreCodeAgentSessionState();
@@ -260,7 +263,6 @@ initConversationScrollMemory();
initCommandBar();
initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
el.logoutButton.addEventListener("click", clearCodeAgentSessionState);
initGateControls();
installWorkbenchTestHooks();
renderStaticWorkbench();
@@ -268,6 +270,7 @@ renderProbePending();
renderCodeAgentSummary();
loadHelpSurface();
loadLiveSurface().then(renderLiveSurface);
hydrateAccountCodeAgentSessionState();
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
function byId(id) {
@@ -460,7 +463,17 @@ function persistCodeAgentSessionState() {
clearCodeAgentSessionState();
return;
}
const payload = {
const payload = codeAgentSessionSnapshotPayload();
try {
window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Keep the in-memory conversation when localStorage is unavailable or full.
}
persistAccountCodeAgentSessionState(payload);
}
function codeAgentSessionSnapshotPayload() {
return {
version: CODE_AGENT_SESSION_STORAGE_VERSION,
updatedAtMs: Date.now(),
conversationId: state.conversationId,
@@ -469,11 +482,117 @@ function persistCodeAgentSessionState() {
sessionStatus: state.sessionStatus,
chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage)
};
try {
window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Keep the in-memory conversation when localStorage is unavailable or full.
}
async function hydrateAccountCodeAgentSessionState() {
const response = await fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=1`, {
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
timeoutName: "Code Agent account conversation hydrate"
});
if (!response.ok) return;
const conversation = response.data?.defaultConversation ?? (Array.isArray(response.data?.conversations) ? response.data.conversations[0] : null);
if (!conversation) {
accountConversationHydrated = true;
if (state.conversationId) persistCodeAgentSessionState();
return;
}
const payload = accountConversationToStoredSessionPayload(conversation);
if (!payload) return;
const shouldReplace = !state.conversationId || Number(payload.updatedAtMs) > latestLocalCodeAgentSessionUpdatedAt();
if (!shouldReplace) {
accountConversationHydrated = true;
persistCodeAgentSessionState();
return;
}
state.conversationId = nonEmptyString(payload.conversationId);
state.sessionId = nonEmptyString(payload.sessionId);
state.threadId = nonEmptyString(payload.threadId);
state.sessionStatus = nonEmptyString(payload.sessionStatus);
state.chatMessages = Array.isArray(payload.chatMessages)
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
: [];
accountConversationHydrated = true;
persistCodeAgentSessionState();
renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult());
renderCodeAgentSummary();
renderConversation();
renderDrafts();
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
}
function accountConversationToStoredSessionPayload(conversation) {
if (!conversation || typeof conversation !== "object") return null;
const conversationId = nonEmptyString(conversation.conversationId);
if (!conversationId) return null;
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const updatedAtMs = Date.parse(conversation.updatedAt ?? conversation.snapshot?.updatedAt ?? "");
return {
version: CODE_AGENT_SESSION_STORAGE_VERSION,
updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(),
conversationId,
sessionId: nonEmptyString(conversation.sessionId ?? conversation.session?.sessionId),
threadId: nonEmptyString(conversation.threadId ?? conversation.session?.threadId),
sessionStatus: nonEmptyString(conversation.snapshot?.sessionStatus ?? conversation.status),
chatMessages: messages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT)
};
}
function latestLocalCodeAgentSessionUpdatedAt() {
let latest = 0;
for (const message of state.chatMessages) {
const value = Date.parse(message.updatedAt ?? message.createdAt ?? "");
if (Number.isFinite(value) && value > latest) latest = value;
}
return latest;
}
function persistAccountCodeAgentSessionState(payload) {
if (!accountConversationHydrated) return;
if (!payload.conversationId) return;
accountConversationPersistPending = true;
if (accountConversationPersistInFlight) return;
void flushAccountCodeAgentSessionState();
}
async function flushAccountCodeAgentSessionState() {
accountConversationPersistInFlight = true;
try {
while (accountConversationPersistPending) {
accountConversationPersistPending = false;
const payload = codeAgentSessionSnapshotPayload();
if (!payload.conversationId) continue;
await fetchJson(`/v1/agent/conversations/${encodeURIComponent(payload.conversationId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
timeoutName: "Code Agent account conversation persist",
body: JSON.stringify({
projectId: WORKBENCH_PROJECT_ID,
conversationId: payload.conversationId,
sessionId: payload.sessionId,
threadId: payload.threadId,
sessionStatus: payload.sessionStatus,
lastTraceId: latestTraceIdFromStoredMessages(payload.chatMessages),
messages: payload.chatMessages,
snapshot: {
source: "cloud-web-account-sync",
updatedAt: new Date(payload.updatedAtMs).toISOString(),
sessionStatus: payload.sessionStatus
}
})
});
}
} finally {
accountConversationPersistInFlight = false;
}
}
function latestTraceIdFromStoredMessages(messages) {
for (const message of [...(messages ?? [])].reverse()) {
const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId);
if (traceId) return traceId;
}
return null;
}
function clearCodeAgentSessionState() {