diff --git a/qa/scenarios/runtime/session-transcript-cli-management.yaml b/qa/scenarios/runtime/session-transcript-cli-management.yaml new file mode 100644 index 000000000000..a838111863d7 --- /dev/null +++ b/qa/scenarios/runtime/session-transcript-cli-management.yaml @@ -0,0 +1,30 @@ +title: Session and transcript CLI management + +scenario: + id: session-transcript-cli-management + surface: runtime + category: session-memory.cli-session-and-transcript-management + coverage: + primary: + - session-memory.cli-session + - session-memory.transcript-management + objective: Prove the real child CLI reads canonical SQLite session state, delegates applied cleanup through a live Gateway, and materializes transcript artifacts from shared SQLite state. + successCriteria: + - The JSON sessions command observes a canonically persisted session. + - Applied missing-transcript cleanup delegates through the live Gateway and removes the targeted session. + - Transcript list and show commands read a stored transcript session as structured JSON. + - Transcript path materializes the JSONL artifact and its persisted utterance. + docsRefs: + - docs/cli/sessions.md + - docs/cli/transcripts.md + codeRefs: + - src/commands/sessions.ts + - src/commands/sessions-cleanup.ts + - src/gateway/server-methods/sessions-read.ts + - src/cli/program/register.transcripts.ts + - src/transcripts/store.ts + - test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts + summary: Run the real child CLI against isolated canonical SQLite state and a live Gateway cleanup boundary. diff --git a/qa/scenarios/runtime/session-transcript-sqlite-durability.yaml b/qa/scenarios/runtime/session-transcript-sqlite-durability.yaml new file mode 100644 index 000000000000..d5acadf04e7e --- /dev/null +++ b/qa/scenarios/runtime/session-transcript-sqlite-durability.yaml @@ -0,0 +1,28 @@ +title: SQLite session and transcript durability + +scenario: + id: session-transcript-sqlite-durability + surface: runtime + category: session-memory.transcript-persistence + coverage: + primary: + - session-memory.transcript-persistence + - session-memory.durability + objective: Prove canonical SQLite session and transcript state survives migration, rollback restore, restart, contention, concurrent clients, and destructive lifecycle operations. + successCriteria: + - Startup import and doctor inspect, validate, rollback restore, and idempotence checkpoints preserve canonical SQLite state. + - Gateway restart and post-restart append retain the expected transcript history. + - SQLite busy contention and concurrent clients commit consistent session and transcript events. + - Cleanup, reset, and delete checkpoints remove targeted state while required transcript archives remain preserved. + docsRefs: + - docs/concepts/session.md + - docs/reference/session-management-compaction.md + codeRefs: + - src/config/sessions/session-accessor.ts + - src/config/sessions/cleanup-service.ts + - test/helpers/sqlite-sessions-transcripts-flip-proof.ts + - test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts + execution: + kind: vitest + path: test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts + summary: Run the existing isolated Gateway lifecycle proof across migration, restart, append, contention, cleanup, reset, delete, archive, and concurrent-client checkpoints. diff --git a/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts new file mode 100644 index 000000000000..0bc07ac49f6b --- /dev/null +++ b/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts @@ -0,0 +1,200 @@ +// QA Lab product proof for canonical session and transcript CLI management. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { upsertSessionEntry } from "../../../../src/config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../../../src/state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../../../../src/state/openclaw-state-db.js"; +import type { + TranscriptSessionDescriptor, + TranscriptUtterance, +} from "../../../../src/transcripts/provider-types.js"; +import { TranscriptsStore } from "../../../../src/transcripts/store.js"; +import { summarizeTranscripts } from "../../../../src/transcripts/summary.js"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "../../../helpers/openclaw-test-instance.js"; + +const SESSION_KEY = "agent:main:qa:missing-transcript"; +const SESSION_ID = "qa-missing-transcript"; +const TRANSCRIPT_ID = "qa-cli-transcript"; +const TRANSCRIPT_STARTED_AT = "2026-08-03T12:00:00.000Z"; +const TRANSCRIPT_TEXT = "Action item: preserve the CLI transcript artifact."; + +type CommandResult = Awaited>; + +type SessionsJson = { + count: number; + sessions: Array<{ key: string; sessionId?: string }>; +}; + +type CleanupJson = { + agentId: string; + applied?: true; + afterCount: number; + beforeCount: number; + dryRun: boolean; + missing: number; +}; + +type TranscriptListJson = Array<{ + sessionId: string; + selector: string; + title?: string; +}>; + +type TranscriptShowJson = { + selector: string; + session: TranscriptSessionDescriptor; + summary: string | null; +}; + +type TranscriptPathJson = { + exists: boolean; + path: string; + selector: string; + sessionId: string; +}; + +let instance: OpenClawTestInstance | undefined; + +afterEach(async () => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + await instance?.cleanup(); + instance = undefined; +}); + +function parseCommandJson(label: string, result: CommandResult): T { + if (result.code !== 0) { + throw new Error( + `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return JSON.parse(result.stdout) as T; +} + +async function seedTranscript(stateDir: string, env: NodeJS.ProcessEnv) { + const session: TranscriptSessionDescriptor = { + sessionId: TRANSCRIPT_ID, + title: "CLI transcript proof", + source: { + providerId: "manual-transcript", + kind: "posthoc-transcript", + }, + startedAt: TRANSCRIPT_STARTED_AT, + stoppedAt: "2026-08-03T12:05:00.000Z", + }; + const utterance: TranscriptUtterance = { + final: true, + speaker: { label: "Operator" }, + text: TRANSCRIPT_TEXT, + }; + const store = new TranscriptsStore(path.join(stateDir, "transcripts"), { env }); + await store.writeSession(session); + await store.appendUtteranceForSession(session, utterance); + await store.writeSummary(summarizeTranscripts({ session, utterances: [utterance] }), session); + return session; +} + +describe("session and transcript child CLI product proof", () => { + it( + "reads canonical SQLite state, delegates cleanup, and materializes transcript artifacts", + { timeout: 180_000 }, + async () => { + instance = await createOpenClawTestInstance({ + name: "qa-session-transcript-cli", + }); + instance.state.applyEnv(); + + await upsertSessionEntry( + { + agentId: "main", + sessionKey: SESSION_KEY, + }, + { + sessionId: SESSION_ID, + updatedAt: Date.now() - 60_000, + }, + ); + const transcriptSession = await seedTranscript(instance.stateDir, instance.env); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + + const sessionsBefore = parseCommandJson( + "sessions --json", + await instance.cli(["sessions", "--json"]), + ); + expect(sessionsBefore.sessions).toContainEqual( + expect.objectContaining({ + key: SESSION_KEY, + sessionId: SESSION_ID, + }), + ); + + await instance.startGateway(); + expect(instance.child).toBeDefined(); + expect(instance.child?.exitCode).toBeNull(); + expect(instance.child?.signalCode).toBeNull(); + expect(instance.child?.killed).toBe(false); + const cleanup = parseCommandJson( + "sessions cleanup", + await instance.cli(["sessions", "cleanup", "--enforce", "--fix-missing", "--json"]), + ); + expect(instance.child).toBeDefined(); + expect(instance.child?.exitCode).toBeNull(); + expect(instance.child?.signalCode).toBeNull(); + expect(instance.child?.killed).toBe(false); + expect(cleanup).toMatchObject({ + agentId: "main", + applied: true, + dryRun: false, + missing: 1, + }); + expect(cleanup.afterCount).toBe(cleanup.beforeCount - 1); + + const sessionsAfter = parseCommandJson( + "sessions --json after cleanup", + await instance.cli(["sessions", "--json"]), + ); + expect(sessionsAfter.sessions.map(({ key }) => key)).not.toContain(SESSION_KEY); + + const transcriptList = parseCommandJson( + "transcripts list", + await instance.cli(["transcripts", "list", "--json"]), + ); + expect(transcriptList).toContainEqual( + expect.objectContaining({ + sessionId: TRANSCRIPT_ID, + selector: "2026-08-03/qa-cli-transcript", + title: "CLI transcript proof", + }), + ); + + const transcriptShow = parseCommandJson( + "transcripts show", + await instance.cli(["transcripts", "show", TRANSCRIPT_ID, "--json"]), + ); + expect(transcriptShow).toMatchObject({ + selector: "2026-08-03/qa-cli-transcript", + session: transcriptSession, + }); + expect(transcriptShow.summary).toContain(TRANSCRIPT_TEXT); + + const transcriptPath = parseCommandJson( + "transcripts path", + await instance.cli(["transcripts", "path", TRANSCRIPT_ID, "--transcript", "--json"]), + ); + expect(transcriptPath).toMatchObject({ + exists: true, + selector: "2026-08-03/qa-cli-transcript", + sessionId: TRANSCRIPT_ID, + }); + expect(transcriptPath.path).toBe( + path.join(instance.stateDir, "transcripts", transcriptPath.selector, "transcript.jsonl"), + ); + await expect(fs.readFile(transcriptPath.path, "utf8")).resolves.toContain(TRANSCRIPT_TEXT); + }, + ); +});