From 9a68ea06e34d8eb1b9e87dab57ae91c79e214a39 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:33:49 +0900 Subject: [PATCH 01/33] docs: record Activity backend roadmap --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index 2b5a2723e..5e43426e8 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit 2b5a2723e1f5cb96fcda6097509aa7b3f3b4a446 +Subproject commit 5e43426e887268c6774ccaf1cd0db6e520e5fc3e From 4afdd7684e7c1ba365fb42fc04b5b0820b7e5f08 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:34:44 +0900 Subject: [PATCH 02/33] feat: expose shared Activity presentation contracts --- src/shared/presentation.ts | 24 +++++++++++++++++ src/shared/runtime-event-parse.ts | 2 ++ tests/unit/presentation.test.ts | 43 +++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/shared/presentation.ts create mode 100644 tests/unit/presentation.test.ts diff --git a/src/shared/presentation.ts b/src/shared/presentation.ts new file mode 100644 index 000000000..c6055f103 --- /dev/null +++ b/src/shared/presentation.ts @@ -0,0 +1,24 @@ +export type PresentationMode = 'activity' | 'legacy'; + +export function isPresentationMode(value: unknown): value is PresentationMode { + return value === 'activity' || value === 'legacy'; +} + +export function presentationMode(settings: unknown): PresentationMode { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return 'activity'; + const block = (settings as Record)['presentation']; + if (!block || typeof block !== 'object' || Array.isArray(block)) return 'activity'; + const mode = (block as Record)['mode']; + return isPresentationMode(mode) ? mode : 'activity'; +} + +/** Captured server response identity; clients must not derive execution scopes. */ +export interface ActivityIdentity { sessionId: string; scope: string; } + +export function parseActivityIdentity(value: unknown): ActivityIdentity | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const input = value as Record; + const id = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 240; + return id(input['sessionId']) && id(input['scope']) + ? { sessionId: input['sessionId'], scope: input['scope'] } : null; +} diff --git a/src/shared/runtime-event-parse.ts b/src/shared/runtime-event-parse.ts index 471b76409..d62dffb5d 100644 --- a/src/shared/runtime-event-parse.ts +++ b/src/shared/runtime-event-parse.ts @@ -33,6 +33,8 @@ function requestView(x: unknown): RuntimeRequestView | null { return { title: x['title'], fields }; } +export { requestView as parseRuntimeRequestView }; + export function parseRuntimeEvent(value: unknown): RuntimeEvent | null { if (!record(value) || value['version'] !== 1 || !id(value['runId']) || !id(value['sessionId']) || !id(value['scope']) || !id(value['turnId']) || diff --git a/tests/unit/presentation.test.ts b/tests/unit/presentation.test.ts new file mode 100644 index 000000000..f62afd8ba --- /dev/null +++ b/tests/unit/presentation.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isPresentationMode, presentationMode, parseActivityIdentity } from '../../src/shared/presentation.js'; +import { parseRuntimeEvent, parseRuntimeRequestView } from '../../src/shared/runtime-event-parse.js'; + +test('display defaults are independent of provider transport and preserve explicit legacy', () => { + for (const input of [undefined, null, [], 42, {}, { presentation: null }, { presentation: [] }, + { presentation: { mode: 'native' } }, { perCli: { claude: { transport: 'native' } } }]) { + assert.equal(presentationMode(input), 'activity'); + } + assert.equal(presentationMode({ presentation: { mode: 'legacy' } }), 'legacy'); + for (const mode of ['activity', 'legacy']) assert.equal(isPresentationMode(mode), true); + for (const mode of ['', 'native', 'print', null, true]) assert.equal(isPresentationMode(mode), false); +}); + +test('server identity is bounded, detached and contains only the public pair', () => { + const input = { sessionId: 'jaw-chat', scope: 'mention-watch:separate', providerSession: 'private' }; + const parsed = parseActivityIdentity(input); + assert.deepEqual(parsed, { sessionId: 'jaw-chat', scope: 'mention-watch:separate' }); + input.scope = 'changed'; + assert.equal(parsed?.scope, 'mention-watch:separate'); + for (const invalid of [null, [], {}, { sessionId: '', scope: 'a' }, { sessionId: 'a', scope: 1 }, + { sessionId: 'a'.repeat(241), scope: 'b' }, { sessionId: 'a', scope: 'b'.repeat(241) }]) { + assert.equal(parseActivityIdentity(invalid), null); + } + assert.ok(parseActivityIdentity({ sessionId: 'a'.repeat(240), scope: 'b'.repeat(240) })); +}); + +test('public request view export uses the existing event parser policy', () => { + const field = { id: 'decision', label: 'Permission', options: [{ id: 'allow', label: 'Allow' }], + multiSelect: false, allowFreeform: false }; + const view = { title: 'Continue?', fields: [field], callback: 'private' }; + const safe = parseRuntimeRequestView(view); + assert.deepEqual(safe, { title: 'Continue?', fields: [field] }); + assert.notEqual(safe?.fields[0], field); + const event = parseRuntimeEvent({ version: 1, runId: 'run', sessionId: 'chat', scope: 'scope', + turnId: 'turn', seq: 1, kind: 'request', requestType: 'approval', requestId: 'request', view }); + assert.ok(event?.kind === 'request'); + assert.deepEqual(event.view, safe); + assert.equal(parseRuntimeRequestView({ ...view, fields: [field, field] }), null); + assert.equal(parseRuntimeRequestView({ ...view, fields: [{ ...field, options: [field.options[0], field.options[0]] }] }), null); + assert.equal(parseRuntimeRequestView({ ...view, title: 'x'.repeat(501) }), null); +}); From a54c8ddfc21bf143dd15ccf5e852b87db01a6cbd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:36:00 +0900 Subject: [PATCH 03/33] feat: bind Activity snapshots to resolved chat identity --- src/routes/orchestrate.ts | 15 ++++- structure/str_func.md | 4 +- tests/unit/activity-identity-snapshot.test.ts | 57 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/unit/activity-identity-snapshot.test.ts diff --git a/src/routes/orchestrate.ts b/src/routes/orchestrate.ts index 5c10fa2fd..01c9ccb4e 100644 --- a/src/routes/orchestrate.ts +++ b/src/routes/orchestrate.ts @@ -7,6 +7,7 @@ import { countToolTraceRows, listToolEntriesForRun } from '../trace/store.js'; import { orchestrate, orchestrateContinue, orchestrateReset, isResetIntent, isContinueIntent, drainPendingReplays } from '../orchestrator/pipeline.js'; import { getSession, insertMessage } from '../core/db.js'; import { getActiveChatSession } from '../core/chat-sessions.js'; +import { resolveRequestSessionStrict } from './session-request.js'; import { getState, getCtx, setState, resetState, canTransition, resetEveryState, parseWorkerVerdict, aggregateBatchVerdicts } from '../orchestrator/state-machine.js'; import type { WorkerVerdict } from '../orchestrator/state-machine.js'; import { normalizeTaskTags } from '../prompt/builder.js'; @@ -273,8 +274,17 @@ export function registerOrchestrateRoutes(app: Express, requireAuth: AuthMiddlew res.json({ ok: true, progress }); }); - app.get('/api/orchestrate/snapshot', requireAuth, (_req, res) => { - const scope = resolveOrcScope({ origin: 'web', workingDir: settings["workingDir"] || null }); + app.get('/api/orchestrate/snapshot', requireAuth, (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const requested = req.query['session']; + if (requested !== undefined && (typeof requested !== 'string' || !requested.trim() || requested.length > 240)) { + fail(res, 400, 'invalid_session'); + return; + } + const resolved = resolveRequestSessionStrict(requested); + if (!resolved.ok) { fail(res, 404, 'unknown_session'); return; } + const scope = resolved.scope; + const activityIdentity = { sessionId: resolved.chatSessionId, scope }; const runtime = getRuntimeSnapshot(scope); const ctx = getCtx(scope); const scopedWorkers = getActiveWorkers(scope); @@ -306,6 +316,7 @@ export function registerOrchestrateRoutes(app: Express, requireAuth: AuthMiddlew researchReport: ctx.researchReport, } : null; res.json({ + activityIdentity, orc: { scope, state: getState(scope), diff --git a/structure/str_func.md b/structure/str_func.md index 5f96350cc..b3c5f98b6 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -346,7 +346,7 @@ cli-jaw/ │ │ ├── jaw-memory.ts ← jaw memory search/read/list/save/init/reflect/flush/soul/soul-activate/bootstrap 라우트 (362L) │ │ ├── jaw-ceo.ts ← Jaw CEO channel/session support routes (321L) ✨ │ │ ├── i18n.ts ← locale bundle 라우트 (35L) -│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1213L) +│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1224L) │ │ ├── memory.ts ← memory status/KV/files/settings 라우트 (191L) │ │ ├── settings.ts ← settings/prompt/project pick/git summary/heartbeat-md/MCP/registry/status/quota/copilot + Pi profile register/model discovery 라우트 + CLI_KEYS 기반 quota parity/status-only metadata (754L) │ │ ├── messaging.ts ← upload/file-open/voice/telegram/channel/discord send 라우트 (513L) @@ -401,7 +401,7 @@ cli-jaw/ │ │ ├── elicitation-spec.ts ← structured elicitation schema + validation helper (167L) │ │ ├── runtime-observability.ts ← worker-run/background-task shared runtime status category vocabulary (40L) │ │ ├── runtime-contract.ts ← native session capabilities, turn outcome and presentation event types (50L) -│ │ ├── runtime-event-parse.ts ← versioned presentation boundary decoder (90L) +│ │ ├── runtime-event-parse.ts ← versioned presentation boundary decoder (92L) │ │ ├── shell-command-display.ts ← shell command display sanitization helper (48L) │ │ ├── structured-fence.ts ← structured renderer fence scanner/parser helper (80L) │ │ ├── tool-log-sanitize.ts ← tool log sanitization helpers (247L) diff --git a/tests/unit/activity-identity-snapshot.test.ts b/tests/unit/activity-identity-snapshot.test.ts new file mode 100644 index 000000000..8d9854c35 --- /dev/null +++ b/tests/unit/activity-identity-snapshot.test.ts @@ -0,0 +1,57 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import express from 'express'; +import { registerOrchestrateRoutes } from '../../src/routes/orchestrate.js'; +import { createChatSession, setActiveChatSession } from '../../src/core/chat-sessions.js'; +import { db } from '../../src/core/db.js'; +import { settings } from '../../src/core/config.js'; +import { beginLiveRun, setLiveRunTraceId, clearLiveRun } from '../../src/agent/live-run-state.js'; + +test('snapshot captures requested/active identity and uses its scope for live state', { timeout: 15_000 }, async () => { + const app = express(); + registerOrchestrateRoutes(app, (_req, _res, next) => next()); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const base = `http://127.0.0.1:${address.port}/api/orchestrate/snapshot`; + const get = (query = '') => fetch(base + query, { signal: AbortSignal.timeout(3_000) }); + const before = settings.multiSession.enabled; + settings.multiSession.enabled = true; + const viewed = createChatSession('activity-viewed'); + const active = createChatSession('activity-active'); + const viewedScope = `local:${viewed.id}`; + beginLiveRun(viewedScope, 'codex'); + setLiveRunTraceId(viewedScope, 'tr_1234567890abcdef'); + try { + const response = await get(`?session=${viewed.id}`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.activityIdentity, { sessionId: viewed.id, scope: viewedScope }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(body.orc.scope, viewedScope); + assert.equal(body.activeRun.traceRunId, 'tr_1234567890abcdef'); + const activeBody = await (await get()).json(); + assert.deepEqual(activeBody.activityIdentity, { sessionId: active.id, scope: `local:${active.id}` }); + assert.equal((await get('?session=deleted-session')).status, 404); + for (const query of ['?session=', '?session=%20', '?session=a&session=b', '?session=' + 'x'.repeat(241)]) { + assert.equal((await get(query)).status, 400, query); + } + const remote = 'jaw:slack:channel:C-activity'; + db.prepare('INSERT INTO remote_session_bindings (remote_key,chat_session_id) VALUES (?,?)').run(remote, viewed.id); + assert.deepEqual((await (await get(`?session=${viewed.id}`)).json()).activityIdentity, + { sessionId: viewed.id, scope: remote }); + settings.multiSession.enabled = false; + const disabled = await (await get('?session=deleted-session')).json(); + assert.deepEqual(disabled.activityIdentity, { sessionId: active.id, scope: 'default' }); + assert.equal(disabled.orc.scope, 'default'); + } finally { + clearLiveRun(viewedScope); + settings.multiSession.enabled = before; + setActiveChatSession('default'); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } +}); From f8dc700190dcf26a4dc9033658a15cf6dd244970 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:38:48 +0900 Subject: [PATCH 04/33] feat: persist Activity display without execution side effects --- src/core/config.ts | 6 + src/core/runtime-settings.ts | 7 +- src/core/settings-merge.ts | 17 +- structure/str_func.md | 6 +- tests/unit/presentation-settings.test.ts | 294 +++++++++++++++++++++++ 5 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 tests/unit/presentation-settings.test.ts diff --git a/src/core/config.ts b/src/core/config.ts index 814d79b54..dc4833b0a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -6,6 +6,7 @@ import path from 'path'; import { join } from 'path'; import { DEFAULT_CLI, CLI_KEYS, buildDefaultPerCli } from '../cli/registry.js'; import { SWITCHABLE_NATIVE_CLIS, resolveRuntimeTransport } from '../agent/runtime/selection.js'; +import { presentationMode } from '../shared/presentation.js'; import type { MessengerChannel } from '../messaging/types.js'; import { pickFirstReadyCli } from '../cli/readiness.js'; import { migrateLegacyClaudeValue } from '../cli/claude-models.js'; @@ -243,6 +244,7 @@ function createDefaultSettings() { permissions: 'auto', workingDir: JAW_HOME, perCli: buildDefaultPerCli(), + presentation: { mode: 'activity' as const }, pi: { defaultProfileId: 'progrok', profiles: [{ @@ -558,6 +560,10 @@ export const isMessengerChannel = (value: unknown): value is MessengerChannel => MESSENGER_CHANNELS.includes(value as MessengerChannel); export function migrateSettings(s: Record, sourceVersion = readSettingsSchemaVersion(s)) { + s['presentation'] = { + ...(isPlainRecord(s['presentation']) ? s['presentation'] : {}), + mode: presentationMode(s), + }; // Whatever the document claimed on the way in, what leaves this function is written // by the current schema and says so. Individual markers below key off `sourceVersion`, // which was read before this line, so stamping here does not disarm them. diff --git a/src/core/runtime-settings.ts b/src/core/runtime-settings.ts index e16d8fcbb..54394fd64 100644 --- a/src/core/runtime-settings.ts +++ b/src/core/runtime-settings.ts @@ -354,6 +354,7 @@ async function applyRuntimeSettingsPatchSerialised( throw new Error('invalid_settings_field'); } const patch = sanitized.value; + const presentationOnly = Object.keys(patch).length === 1 && Object.hasOwn(patch, 'presentation'); validateDispatchApprovalPatch(patch); if (!opts.allowWikiLifecycle && wikiRouteManagedPatchPaths(patch).length > 0) { throw new Error('wiki_configuration_requires_wiki_route'); @@ -381,7 +382,7 @@ async function applyRuntimeSettingsPatchSerialised( }); } - opts.resetFallbackState?.(); + if (!presentationOnly) opts.resetFallbackState?.(); // CLI-changed branch delegates main-session clearing to cliSwitchRefresh // (which writes a cleared row inside its DB transaction). On refresh failure @@ -412,11 +413,11 @@ async function applyRuntimeSettingsPatchSerialised( rollbackHandled = true; throw e; } - } else { + } else if (!presentationOnly) { syncMainSessionToSettings(prevCli); } - syncJwcConfigDefault(settings); + if (!presentationOnly) syncJwcConfigDefault(settings); if (settings["workingDir"] !== prevWorkingDir) { try { diff --git a/src/core/settings-merge.ts b/src/core/settings-merge.ts index cf0e671ec..1c8e461c5 100644 --- a/src/core/settings-merge.ts +++ b/src/core/settings-merge.ts @@ -4,6 +4,7 @@ import { mergeAckSettings } from '../messaging/ack-reaction.js'; import { mergeSlackAutoJoin } from '../slack/auto-join.js'; import { isRuntimeTransport, isSwitchableNativeCli } from '../agent/runtime/selection.js'; +import { isPresentationMode } from '../shared/presentation.js'; export type SettingsInputSource = 'boot' | 'watch' | 'api'; export type SettingsPersistenceShape = 'absent' | 'present'; @@ -36,6 +37,20 @@ export function sanitizeSettingsInput( const rejectedPaths: string[] = []; let persistenceShape: SettingsPersistenceShape = 'absent'; + if (Object.hasOwn(input, 'presentation')) { + if (!isPlainRecord(input['presentation'])) { + delete value['presentation']; + invalidPaths.push('presentation'); + } else { + const presentation = { ...input['presentation'] }; + if (Object.hasOwn(presentation, 'mode') && !isPresentationMode(presentation['mode'])) { + delete presentation['mode']; + invalidPaths.push('presentation.mode'); + } + value['presentation'] = presentation; + } + } + if (isPlainRecord(input['perCli'])) { const perCli = Object.fromEntries(Object.entries(input['perCli']).map(([cli, entry]) => { if (!isPlainRecord(entry)) return [cli, entry]; @@ -154,7 +169,7 @@ export function mergeSettingsPatch(current: Record, patch: Record }> = []; +const listener = (type: string, data: Record) => events.push({ type, data }); +bus.addBroadcastListener(listener); + +function writeDocument(value: unknown): void { + writeFileSync(config.SETTINGS_PATH, JSON.stringify(value, null, 2)); +} + +function disk(): string { return readFileSync(config.SETTINGS_PATH, 'utf8'); } +function session(): MainSessionRecord { return database.getSession() as MainSessionRecord; } + +async function apply(patch: Record) { + return runtime.applyRuntimeSettingsPatch(patch, { + resetFallbackState: () => { resets += 1; }, + restartMessaging: async (prev, next, actualPatch) => { + dispatches += 1; + return messaging.restartMessagingRuntime(prev, next, actualPatch); + }, + }); +} + +beforeEach(async () => { + const initial = structuredClone(config.DEFAULT_SETTINGS); + writeDocument({ + ...initial, + cli: 'jwc', + workingDir: home, + presentation: { mode: 'legacy', sibling: 'preserve' }, + perCli: { ...initial.perCli, jwc: { provider: 'anthropic', model: 'settings-model' } }, + messaging: { ...initial.messaging, enabledChannels: ['telegram'], homeChannel: 'telegram' }, + }); + config.loadSettings(); + database.updateSession.run('jwc', 'native-session-sentinel', 'singleton-model', 'auto', home, 'high'); + database.upsertSessionBucket.run(nativeBucket, 'native-resume-sentinel', 'native-model', 'resume-key', 17); + writeFileSync(jwcPath, jwcSentinel); + messaging.__resetTransportRegistryForTests(); + // Only the external transport init/shutdown boundaries are fake. The restart + // dispatcher, registry, running state, persistence, session and bus are real. + for (const channel of ['telegram', 'discord', 'slack'] as const) { + messaging.registerTransport(channel, { + init: async () => { starts += 1; return messaging.transportStarted; }, + shutdown: async () => { stops += 1; }, + }); + } + await messaging.startMessagingTransport('telegram'); + resets = dispatches = starts = stops = 0; + events.length = 0; +}); + +after(() => { + bus.removeBroadcastListener(listener); + messaging.__resetTransportRegistryForTests(); + database.db.close(); + rmSync(home, { recursive: true, force: true }); +}); + +test('presentation-only does not reset fallback state', async () => { + await apply({ presentation: { mode: 'activity' } }); + assert.equal(resets, 0); +}); + +test('presentation-only preserves the actual singleton session row', async () => { + const before = database.getSession(); + await apply({ presentation: { mode: 'activity' } }); + assert.deepEqual(database.getSession(), before); +}); + +test('presentation-only does not write the isolated JWC config', async () => { + await apply({ presentation: { mode: 'activity' } }); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); +}); + +test('presentation-only persists, broadcasts and dispatches without changing transport or native identity', async () => { + const perCli = structuredClone(config.settings['perCli']); + const bucket = database.getSessionBucket.get(nativeBucket); + await apply({ presentation: { mode: 'activity' } }); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(JSON.parse(disk()).presentation, { mode: 'activity', sibling: 'preserve' }); + assert.equal(config.settings['cli'], 'jwc'); + assert.deepEqual(config.settings['perCli'], perCli); + assert.deepEqual(database.getSessionBucket.get(nativeBucket), bucket); + assert.equal(dispatches, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); + assert.deepEqual(messaging.getRunningMessagingTransports(), ['telegram']); + assert.deepEqual(events.filter(e => e.type === 'settings_change').map(e => e.data['changedKeys']), [['presentation']]); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(config.settings['perCli'], perCli); +}); + +test('empty and partial presentation blocks retain explicit legacy and siblings', async () => { + await apply({ presentation: {} }); + await apply({ presentation: { sibling: 'updated' } }); + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); + assert.equal(resets, 0); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); +}); + +for (const [name, patch] of [ + ['mixed', { presentation: { mode: 'activity' }, locale: 'en' }], + ['empty', {}], +] as const) { + test(`${name} patch preserves existing fallback, session and JWC side effects`, async () => { + await apply(patch); + assert.equal(resets, 1); + assert.equal(session().model, 'settings-model'); + assert.equal(session().session_id, 'native-session-sentinel'); + assert.equal(readFileSync(jwcPath, 'utf8'), 'modelRoles:\n default: anthropic/settings-model\ncustom: keep\n'); + assert.equal(dispatches, 1); + assert.equal(starts, name === 'mixed' ? 1 : 0); + assert.equal(stops, name === 'mixed' ? 1 : 0); + }); +} + +test('inherited presentation is an empty patch, while inherited unrelated keys do not defeat an own presentation-only patch', async () => { + await apply(Object.create({ presentation: { mode: 'activity' } })); + assert.equal(resets, 1); + assert.equal(session().model, 'settings-model'); + assert.equal(config.settings['presentation'].mode, 'legacy'); + await apply(Object.assign(Object.create({ locale: 'en' }), { presentation: { mode: 'activity' } })); + assert.equal(resets, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); +}); + +test('presentation-only write failure leaves disk, memory, session and broadcasts untouched', async () => { + const before = config.snapshotSettingsState(); + const raw = disk(); + await assert.rejects(runtime.applyRuntimeSettingsPatch({ presentation: { mode: 'activity' } }, { + writeSettings: () => { throw new Error('presentation write failed'); }, + resetFallbackState: () => { resets += 1; }, + }), /presentation write failed/); + assert.deepEqual(config.snapshotSettingsState(), before); + assert.equal(disk(), raw); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(resets, 0); + assert.equal(events.length, 0); +}); + +test('presentation-only dispatcher failure retains the existing persistence rollback', async () => { + config.saveSettings(config.settings); + const before = config.snapshotSettingsState(); + const raw = disk(); + await assert.rejects(runtime.applyRuntimeSettingsPatch({ presentation: { mode: 'activity' } }, { + restartMessaging: async () => { throw new Error('presentation dispatcher failed'); }, + resetFallbackState: () => { resets += 1; }, + }), /presentation dispatcher failed/); + assert.deepEqual(config.snapshotSettingsState(), before); + assert.equal(disk(), raw); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(resets, 0); + assert.equal(events.length, 0); +}); + +test('fresh defaults and old absent documents resolve activity; explicit legacy survives save/reload', () => { + assert.deepEqual(config.DEFAULT_SETTINGS.presentation, { mode: 'activity' }); + assert.deepEqual(config.settingsForHomeWithoutSettingsFile().presentation, { mode: 'activity' }); + for (const presentation of [undefined, { mode: 'legacy', sibling: 'kept' }]) { + writeDocument({ cli: 'jwc', workingDir: home, ...(presentation ? { presentation } : {}) }); + config.loadSettings(); + const expected = presentation ?? { mode: 'activity' }; + assert.deepEqual(config.settings['presentation'], expected); + config.saveSettings(config.settings); + assert.deepEqual(JSON.parse(disk()).presentation, expected); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], expected); + } +}); + +test('boot repairs invalid presentation values and direct migration never spreads malformed blocks', () => { + for (const presentation of [null, [], 'legacy', 42, { mode: 'native', sibling: 'kept' }]) { + writeDocument({ cli: 'jwc', workingDir: home, presentation }); + config.loadSettings(); + const expected = presentation && typeof presentation === 'object' && !Array.isArray(presentation) + ? { mode: 'activity', sibling: 'kept' } : { mode: 'activity' }; + assert.deepEqual(config.settings['presentation'], expected); + assert.deepEqual(config.migrateSettings({ ...config.settings, presentation })['presentation'], expected); + config.saveSettings(config.settings); + config.loadSettings(); + assert.deepEqual(config.settings['presentation'], expected); + } +}); + +test('invalid API presentation is rejected with no memory, disk or runtime side effects', async () => { + const app = express(); + app.use(express.json()); + registerSettingsRoutes(app, (_req, _res, next) => next(), apply, process.cwd()); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + try { + for (const presentation of [null, [], 'legacy', 1, { mode: 'native' }, { mode: null }, { mode: 1 }]) { + const patch = { presentation }; + const before = config.snapshotSettingsState(); + const raw = disk(); + const expectedPath = presentation && typeof presentation === 'object' && !Array.isArray(presentation) + ? 'presentation.mode' : 'presentation'; + assert.deepEqual(sanitizeSettingsInput(patch, 'api').invalidPaths, [expectedPath]); + await assert.rejects(apply(patch), /invalid_settings_field/); + const response = await fetch(`http://127.0.0.1:${address.port}/api/settings`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 400); + assert.equal((await response.json()).error, 'invalid_settings_field'); + assert.equal(disk(), raw); + assert.deepEqual(config.snapshotSettingsState(), before); + } + assert.equal(resets, 0); + assert.equal(dispatches, 0); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(events.length, 0); + const response = await fetch(`http://127.0.0.1:${address.port}/api/settings`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ presentation: { mode: 'activity' } }), signal: AbortSignal.timeout(5000), + }); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).data.presentation, { mode: 'activity', sibling: 'preserve' }); + assert.deepEqual(JSON.parse(disk()).presentation, { mode: 'activity', sibling: 'preserve' }); + assert.equal(resets, 0); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + assert.equal(dispatches, 1); + assert.equal(starts, 0); + assert.equal(stops, 0); + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } +}); + +test('real disk watcher ingress sanitizes invalid fields, retains legacy and merges siblings', () => { + const warnings: string[] = []; + const previousWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')); }; + try { + for (const presentation of [null, [], 'bad', { mode: 'invalid', sibling: 'updated' }]) { + writeDocument({ presentation }); + const raw = disk(); + assert.equal(watcher.reloadSettingsFromDisk({ lastSavedRaw: null }), true); + assert.equal(config.settings['presentation'].mode, 'legacy'); + assert.equal(disk(), raw, 'watch reload does not rewrite the external file'); + } + assert.deepEqual(config.settings['presentation'], { mode: 'legacy', sibling: 'updated' }); + assert.ok(warnings.some(line => line.includes('presentation.mode'))); + assert.ok(warnings.some(line => line.endsWith('presentation'))); + writeDocument({ presentation: { mode: 'activity' } }); + assert.equal(watcher.reloadSettingsFromDisk({ lastSavedRaw: null }), true); + assert.deepEqual(config.settings['presentation'], { mode: 'activity', sibling: 'updated' }); + assert.equal(session().model, 'singleton-model'); + assert.equal(readFileSync(jwcPath, 'utf8'), jwcSentinel); + } finally { + console.warn = previousWarn; + } +}); From 7bdfffabc5f5aacaa68a29503dd3c1dc3711d39f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:39:53 +0900 Subject: [PATCH 05/33] docs: describe Activity identity and display settings --- AGENTS.md | 2 ++ CLAUDE.md | 2 ++ README.md | 5 +++++ structure/AGENTS.md | 2 ++ structure/runtime-integration.md | 15 +++++++++++++++ structure/server_api.md | 8 ++++++++ 6 files changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f3551bea3..2a8a4a4e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,8 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Native decisions +Activity display is selected by `presentation.mode` (`activity` default, explicit `legacy` retained), separately from provider transport. Snapshot `GET /api/orchestrate/snapshot?session=...` supplies captured `activityIdentity={sessionId,scope}`; clients validate it before semantic admission. Presentation-only settings writes must not reset fallback state or synchronize execution configuration. Existing instance auth and disabled multi-session resolver policy remain. + `src/agent/runtime/requests.ts` and `acp/callbacks.ts` own bounded pending decisions, opaque native-option mapping and cancellation latches. `GET /api/runtime/requests?sessionId=...` and `POST /api/runtime/requests/:id` use the existing instance auth policy (including loopback/LAN bypass), never a current-session fallback. Match run/session/scope/turn and current ownership before answering. Canonical sanitization and the32KiB event preflight precede insertion; global128/120s and per-connection32 bounds apply. An admitted selected write cannot be retracted: cancellation during dispatch retires the connection. Provider activation, approval UI and messaging changes are not implied. Sync `structure/runtime-integration.md` and `structure/server_api.md`. ### Concurrent inbound gateway (M1) diff --git a/CLAUDE.md b/CLAUDE.md index 4479ede94..6f85b7d8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc ## Documentation Map +- Activity uses `presentation.mode` (`activity` by default, reversible `legacy`) independently of provider transport. `GET /api/orchestrate/snapshot?session=...` returns server-owned `activityIdentity`; validate it before semantic admission. Display-only writes preserve runtime selection and delivery. See `structure/runtime-integration.md`. + - Start at `structure/INDEX.md` for the current architecture map. - Keep `README.md`, `AGENTS.md`, this file, and `structure/AGENTS.md` aligned when command/API/orchestration behavior changes. Concurrent inbound gateway changes belong in `structure/INDEX.md`, `structure/infra.md`, `structure/telegram.md`, and the messaging runtime docs. - Do not use the old `devlog/structure/` path for architecture docs; the active folder is `structure/`. diff --git a/README.md b/README.md index ab0f46f69..1feb6d007 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ ## Install +Conversation display uses `presentation.mode`: `activity` by default, or `legacy` for +the previous transcript view. This preference is independent of provider transport. +Activity clients obtain their chat identity from the server snapshot before subscribing +to semantic updates; see [runtime integration](structure/runtime-integration.md). +
Safe install — for existing users who want minimal changes diff --git a/structure/AGENTS.md b/structure/AGENTS.md index 62d861a21..397a46428 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -2,6 +2,8 @@ # structure/ — Sync Guide +- Activity identity and display settings: `shared/presentation.ts`, config/settings-merge, runtime-settings and orchestrate snapshot share the contract in `runtime-integration.md` and `server_api.md`. Mode is independent of transport; snapshot identity is server-owned, including when multi-session is disabled. + - Keep this folder aligned with the live `cli-jaw` tree. The current hub covers 19 Markdown docs plus 5 support files. - Update `INDEX.md` whenever a doc is added, removed, renamed, or re-scoped. Keep the doc map, tier list, and quick links in sync. - Update `str_func.md` and `verify-counts.sh` together when source counts, `server.ts`, `src/routes/*`, `src/cli/handlers*.ts`, `src/cli/api-auth.ts`, `src/manager/*` (multi-instance dashboard), `bin/commands/*`, `bin/star-prompt.ts`, `tests/`, `public/`, or generated-dist exclusions change. The verifier now checks every file-tree `(NNNL)` entry in `str_func.md`, not only curated hotspots. diff --git a/structure/runtime-integration.md b/structure/runtime-integration.md index a19d68320..dbc699cc8 100644 --- a/structure/runtime-integration.md +++ b/structure/runtime-integration.md @@ -8,6 +8,21 @@ tags: [cli-jaw, codex-app, pi, opencodex, runtime-pool] ## Shared event contract foundation +`presentation.mode` selects `activity` (fresh and upgraded absent setting) or `legacy`. +Explicit legacy survives save/reload. It does not select a provider transport. A +presentation-only settings PATCH preserves execution configuration, session selection, +fallback state and external delivery; mixed patches retain their existing behavior. + +`GET /api/orchestrate/snapshot?session=` returns a bare snapshot with +`activityIdentity: {sessionId, scope}` alongside the existing fields. The same captured +scope selects its orchestrator, live run, workers and queue. Malformed selectors are400; +unknown selectors are404 when multi-session is enabled. With that feature disabled the +existing resolver returns the actual active chat and default execution scope. Omitting +the selector also uses the active chat. Clients use `parseActivityIdentity` before +semantic admission and never derive a native session ID or scope from UI state. +`parseRuntimeRequestView` exposes the existing request-view validator without widening +the RuntimeEvent schema. Snapshot responses are no-store; auth remains instance-level. + `src/shared/runtime-contract.ts` defines native/print capabilities, distinct native-input/cancel-reprompt/queued/restart controls, and versioned presentation events. A jaw chat session and routing scope are separate from private provider session IDs. `RuntimeTurnOutcome` keeps authoritative `finalText` (null means absent; an empty string is intentional) separate from partial text. `src/agent/runtime/events.ts` records a validated, redacted body through the existing trace writer before publishing `agent_runtime` on the agent event topic. The trace writer owns sequence allocation; sequence gaps are valid. The tuple codec in `src/trace/runtime-body-codec.ts` preserves numeric usage without weakening raw-trace secret masking. Known structured fragments must be sanitized before clipping by their producer. Recording failure returns null, never a fabricated event or another inference. diff --git a/structure/server_api.md b/structure/server_api.md index 1092f6d6f..0efc01476 100644 --- a/structure/server_api.md +++ b/structure/server_api.md @@ -8,6 +8,14 @@ aliases: [CLI-JAW Server API, server.ts reference, server_api] # server.ts — Glue + Route Registration (757L) +Activity identity: `GET /api/orchestrate/snapshot?session=` adds +`activityIdentity: {sessionId,scope}` to the existing bare snapshot. Its runtime and +orchestrator data use the same captured scope. Optional selectors must be nonblank +scalar strings of at most240 characters; malformed400, enabled unknown-session404. +Disabled multi-session preserves the existing active-chat/default-scope policy. +Responses are no-store. `presentation.mode` is `activity` by default or explicit +`legacy`, independent of transport; PUT `/api/settings` validates and merges it. + > Express/SSE bootstrap + localhost/LAN opt-in 보안 가드 + `src/routes/*` registrar + mounted sub-router 등록. > Route-module inventory and the endpoint contracts below describe the surface; aggregate handler counts are not maintained by hand. > mutation route(`POST`/`PUT`/`DELETE`)는 모두 `requireAuth`를 거친다. 단, `requireAuth()`는 loopback 요청을 토큰 없이 통과시키고, `lanAllowed()`가 true일 때 private IP도 LAN bypass로 통과시킨다. From 0f986404cc3dd02a9d11441fa288e04d281b0dcd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:41:34 +0900 Subject: [PATCH 06/33] docs: checkpoint verified Activity foundation --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index 5e43426e8..3b57282ff 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit 5e43426e887268c6774ccaf1cd0db6e520e5fc3e +Subproject commit 3b57282ff32cbe5ec157145c92418d349ab1f547 From 52110fb1d99a4a6d94896ae050eeea72ba49df60 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:00:14 +0900 Subject: [PATCH 07/33] feat: persist bounded owner-bound Activity journal and replay --- src/agent/runtime/events.ts | 12 +- src/core/bus.ts | 6 + src/core/chat-sessions.ts | 1 + src/core/db.ts | 15 ++ src/routes/traces.ts | 99 ++++++- src/trace/activity-control.ts | 75 ++++++ src/trace/activity-journal.ts | 153 +++++++++++ src/trace/activity-retention.ts | 54 ++++ src/trace/store.ts | 44 ++-- src/trace/types.ts | 2 + tests/unit/activity-journal.test.ts | 314 +++++++++++++++++++++++ tests/unit/activity-routes.test.ts | 303 ++++++++++++++++++++++ tests/unit/native-acp-callbacks.test.ts | 2 +- tests/unit/runtime-event-emitter.test.ts | 7 +- 14 files changed, 1059 insertions(+), 28 deletions(-) create mode 100644 src/trace/activity-control.ts create mode 100644 src/trace/activity-journal.ts create mode 100644 src/trace/activity-retention.ts create mode 100644 tests/unit/activity-journal.test.ts create mode 100644 tests/unit/activity-routes.test.ts diff --git a/src/agent/runtime/events.ts b/src/agent/runtime/events.ts index 4b44bb587..b21e078b2 100644 --- a/src/agent/runtime/events.ts +++ b/src/agent/runtime/events.ts @@ -1,5 +1,5 @@ import type { RuntimeEvent, RuntimeEventBody, RuntimeEventIdentity } from '../../shared/runtime-contract.js'; -import { appendTraceEvent } from '../../trace/store.js'; +import { appendActivityBody, markActivityFailure } from '../../trace/activity-journal.js'; import { publish } from '../../core/event-bus.js'; import { stringifyTraceValue } from '../../trace/redact.js'; import { encodeRuntimeBody, decodeRuntimeBody, RUNTIME_BODY_BYTES } from '../../trace/runtime-body-codec.js'; @@ -21,18 +21,22 @@ export function recordRuntimeEvent(context: RuntimeEventContext, body: RuntimeEv }; const encoded = encodeRuntimeBody(identity, body); const serialized = stringifyTraceValue(encoded.raw); - if (Buffer.byteLength(serialized, 'utf8') > RUNTIME_BODY_BYTES) return null; + if (Buffer.byteLength(serialized, 'utf8') > RUNTIME_BODY_BYTES) { + markActivityFailure(context, 'event_limit'); + return null; + } const raw: unknown = JSON.parse(serialized); // Validate what will actually be stored before consuming a trace seq. if (!decodeRuntimeBody(raw, identity, body.kind)) return null; - const pointer = appendTraceEvent({ runId: context.runId, source: 'runtime', - eventType: body.kind, raw, preview: body.kind }); + const pointer = appendActivityBody({ runId: context.runId, sessionId: context.sessionId, + scope: context.scope, audience: context.audience, eventType: body.kind, raw: encoded.raw }); if (!pointer) return null; const event = decodeRuntimeBody(raw, { ...identity, seq: pointer.traceSeq }, body.kind); if (!event) return null; if (context.audience === 'public') publish('agent', 'agent_runtime', { ...event }); return event; } catch { + markActivityFailure(context, 'storage_error'); console.warn('[runtime] projection_record_failed'); return null; } diff --git a/src/core/bus.ts b/src/core/bus.ts index d340ac394..ea8c58b65 100644 --- a/src/core/bus.ts +++ b/src/core/bus.ts @@ -55,6 +55,12 @@ export function inferTopic(type: string): EventTopic { } export function broadcast(type: string, data: Record, audience: 'public' | 'internal' = 'public') { + // Semantic presentation is never input to collectors, forwarders or ACK owners. + // Its producer supplies captured identity, even when multi-session is disabled. + if (type === 'agent_runtime' || type === 'agent_runtime_gap') { + if (audience === 'public') ssePublish('agent', type, data); + return; + } const captured = currentSessionScope(); const scopedData = captured && settings["multiSession"]?.enabled === true ? { ...data, scope: data["scope"] ?? captured.scope, sessionId: data["sessionId"] ?? captured.chatSessionId } diff --git a/src/core/chat-sessions.ts b/src/core/chat-sessions.ts index 1e37e21ee..83764c613 100644 --- a/src/core/chat-sessions.ts +++ b/src/core/chat-sessions.ts @@ -193,6 +193,7 @@ export function deleteChatSession(sessionId: string): boolean { const result = deleteStmt.run(sessionId); if (result.changes === 0) return null; deleteMessagesStmt.run(sessionId); + db.prepare('DELETE FROM trace_runs WHERE session_id = ?').run(sessionId); return row; })(); if (deleted) { diff --git a/src/core/db.ts b/src/core/db.ts index 7466408d4..187c2d106 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -345,6 +345,17 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_trace_events_run_seq ON trace_events(run_id, seq); `); +// Additive owner metadata: legacy rows remain readable without invented scopes. +const traceRunCols = new Set((db.prepare('PRAGMA table_info(trace_runs)').all() as { name: string }[]).map(c => c.name)); +if (!traceRunCols.has('session_id')) db.exec('ALTER TABLE trace_runs ADD COLUMN session_id TEXT'); +if (!traceRunCols.has('scope_key')) db.exec('ALTER TABLE trace_runs ADD COLUMN scope_key TEXT'); +db.exec(` + CREATE INDEX IF NOT EXISTS idx_trace_runs_session ON trace_runs(session_id, id); + CREATE INDEX IF NOT EXISTS idx_trace_runtime ON trace_events(run_id, seq) WHERE source = 'runtime'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_runtime_control ON trace_events(run_id) + WHERE source = 'system' AND event_type = 'runtime.control.v1'; +`); + // Lightweight migration for existing DBs created before `trace` column existed. const messageCols = db.prepare('PRAGMA table_info(messages)').all(); if (!(messageCols as Record[]).some(c => c["name"] === 'trace')) { @@ -373,6 +384,10 @@ if (!(messageCols as Record[]).some(c => c["name"] === 'session db.exec("ALTER TABLE messages ADD COLUMN session_id TEXT DEFAULT 'default'"); } db.exec('CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)'); +// Only the original message link can establish a historical owner. Forked copies +// also carry trace_run_id, so backfilling from that column would steal ownership. +db.exec(`UPDATE trace_runs SET session_id = (SELECT session_id FROM messages WHERE id = trace_runs.message_id) + WHERE session_id IS NULL AND message_id IS NOT NULL`); const SEARCH_FTS_SQL = ` CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( diff --git a/src/routes/traces.ts b/src/routes/traces.ts index 45239a5ae..bf93ab70b 100644 --- a/src/routes/traces.ts +++ b/src/routes/traces.ts @@ -1,8 +1,43 @@ import type { Express, NextFunction, Request, Response } from 'express'; import { fail, ok } from '../http/response.js'; import { getTraceEvent, getTraceRun, listTraceEvents } from '../trace/store.js'; +import { isTraceSessionOwner, listActivityRuns, readActivityPage, ACTIVITY_PAGE_ROWS as ACTIVITY_PAGE_SIZE } from '../trace/activity-journal.js'; type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void; +const TRACE_ID_RE = /^tr_[A-Za-z0-9_-]{16,80}$/; + +function explicitSession(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 240; +} + +function decimalQuery(value: unknown, fallback: number): number { + if (value === undefined) return fallback; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return NaN; + const n = Number(value); + return Number.isSafeInteger(n) ? n : NaN; +} + +function activityQuery(req: Request) { + const query = req.query; + if (Object.keys(query).some(key => !['session', 'after', 'through', 'limit'].includes(key))) return null; + const session = query['session']; + if (!explicitSession(session)) return null; + const after = decimalQuery(query['after'], 0); + const through = decimalQuery(query['through'], 0); + const limit = decimalQuery(query['limit'], ACTIVITY_PAGE_SIZE); + if (![after, through, limit].every(Number.isSafeInteger) || limit < 1 || limit > ACTIVITY_PAGE_SIZE) return null; + return { sessionId: session, after, limit, ...(query['through'] === undefined ? {} : { through }) }; +} + +function rawRead(handler: (req: Request, res: Response) => void) { + return (req: Request, res: Response): void => { + try { + handler(req, res); + } catch { + fail(res, 503, 'trace_unavailable'); + } + }; +} function parseLimit(value: unknown, fallback: number): number { const n = Number(value); @@ -22,11 +57,63 @@ function publicRunOrFail(req: Request, res: Response) { fail(res, 404, 'trace_not_found'); return null; } + // Session-only backfills remain readable as raw diagnostics. Scope alone + // cannot authorize access, and only truly ownerless rows retain legacy access. + if (run.session_id != null || run.scope_key != null) { + const session = req.query['session']; + if (!explicitSession(session) || session !== run.session_id || !isTraceSessionOwner(run.id, session)) { + fail(res, 404, 'trace_not_found'); + return null; + } + } return run; } export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): void { - app.get('/api/traces/:runId', requireAuth, (req, res) => { + // Set before auth and parameter parsing so failures cannot be cached either. + app.use('/api/traces', (_req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); + }); + + app.get('/api/traces/activity-runs', requireAuth, (req, res) => { + const query = req.query; + const session = query['session']; + const after = query['after'] ?? ''; + if (Object.keys(query).some(key => !['session', 'after'].includes(key)) + || !explicitSession(session) || typeof after !== 'string' + || (after !== '' && !TRACE_ID_RE.test(after))) { + fail(res, 400, 'invalid_activity_query'); + return; + } + try { + ok(res, { runs: listActivityRuns(session, after), pageSize: ACTIVITY_PAGE_SIZE }); + } catch { + fail(res, 503, 'activity_unavailable'); + } + }); + + app.get('/api/traces/:runId/activity', requireAuth, (req, res) => { + const query = activityQuery(req); + const runId = String(req.params['runId'] || ''); + if (!query || !TRACE_ID_RE.test(runId)) { + fail(res, 400, 'invalid_activity_query'); + return; + } + try { + const page = readActivityPage({ runId, ...query }); + if (!page) { + fail(res, 404, 'trace_not_found'); + return; + } + ok(res, page); + } catch (error) { + fail(res, error instanceof RangeError ? 409 : 503, + error instanceof RangeError ? 'activity_resync_required' : 'activity_unavailable'); + } + }); + + app.get('/api/traces/:runId', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; ok(res, { @@ -44,15 +131,15 @@ export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): finishedAt: run.finished_at || null, error: run.error || null, }); - }); + })); - app.get('/api/traces/:runId/events', requireAuth, (req, res) => { + app.get('/api/traces/:runId/events', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; ok(res, listTraceEvents(run.id, parseOffset(req.query["offset"]), parseLimit(req.query["limit"], 80))); - }); + })); - app.get('/api/traces/:runId/events/:seq', requireAuth, (req, res) => { + app.get('/api/traces/:runId/events/:seq', requireAuth, rawRead((req, res) => { const run = publicRunOrFail(req, res); if (!run) return; const seq = Number(req.params["seq"]); @@ -76,5 +163,5 @@ export function registerTraceRoutes(app: Express, requireAuth: AuthMiddleware): createdAt: event.created_at || 0, raw: event.raw, }); - }); + })); } diff --git a/src/trace/activity-control.ts b/src/trace/activity-control.ts new file mode 100644 index 000000000..0d8482b35 --- /dev/null +++ b/src/trace/activity-control.ts @@ -0,0 +1,75 @@ +import { db } from '../core/db.js'; + +export const ACTIVITY_CONTROL_TYPE = 'runtime.control.v1'; +export type ActivityLoss = 'event_limit' | 'run_limit' | 'global_limit' | 'storage_error' | 'retention'; +export type ActivityControl = { + version: 1; count: number; bytes: number; lastSeq: number; closed: boolean; loss: ActivityLoss | null; +}; +const CONTROL_BYTES = 2048; +const losses = new Set(['event_limit', 'run_limit', 'global_limit', 'storage_error', 'retention']); +const read = db.prepare(`SELECT seq, + CASE WHEN length(CAST(raw_json AS BLOB)) <= ${CONTROL_BYTES} THEN raw_json ELSE NULL END AS raw_json + FROM trace_events WHERE run_id = ? AND source = 'system' AND event_type = 'runtime.control.v1'`); +const write = db.prepare(`UPDATE trace_events SET raw_json = ?, bytes = ?, preview = 'runtime control' + WHERE run_id = ? AND seq = ? AND source = 'system' AND event_type = 'runtime.control.v1'`); + +export function readActivityControl(runId: string): { seq: number; state: ActivityControl } | null { + const row = read.get(runId) as { seq: number; raw_json: string | null } | undefined; + if (!row) return null; + if (row.raw_json === null) throw new Error('activity_control_corrupt'); + let value: unknown; + try { value = JSON.parse(row.raw_json); } + catch { throw new Error('activity_control_corrupt'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('activity_control_corrupt'); + const c = value as Record; + if (Object.keys(c).some(k => !['version', 'count', 'bytes', 'lastSeq', 'closed', 'loss'].includes(k)) + || c['version'] !== 1 || typeof c['closed'] !== 'boolean' + || !['count', 'bytes', 'lastSeq'].every(k => Number.isSafeInteger(c[k]) && Number(c[k]) >= 0) + || (c['loss'] !== null && !losses.has(c['loss'] as ActivityLoss))) throw new Error('activity_control_corrupt'); + return { seq: row.seq, state: { version: 1, count: Number(c['count']), bytes: Number(c['bytes']), + lastSeq: Number(c['lastSeq']), closed: c['closed'], loss: c['loss'] as ActivityLoss | null } }; +} + +export function writeActivityControl(runId: string, seq: number, state: ActivityControl): void { + const raw = JSON.stringify(state); + if (write.run(raw, Buffer.byteLength(raw), runId, seq).changes !== 1) throw new Error('activity_control_missing'); +} + +/** Best-effort metadata must never obstruct final delivery or interrupted MESSAGE salvage. */ +export function markActivityLoss(runId: string, loss: ActivityLoss): void { + try { + db.transaction(() => { + const current = readActivityControl(runId); + if (current && !current.state.loss) writeActivityControl(runId, current.seq, { ...current.state, loss }); + }).immediate(); + } catch { console.warn('[activity] loss_metadata_unavailable'); } +} + +export function closeActivity(runId: string, loss?: ActivityLoss): void { + try { + db.transaction(() => { + const current = readActivityControl(runId); + if (!current) return; + writeActivityControl(runId, current.seq, { ...current.state, closed: true, + loss: current.state.loss ?? loss ?? (current.state.closed ? null : 'storage_error') }); + }).immediate(); + } catch { console.warn('[activity] close_metadata_unavailable'); } +} + +/** Delete a whole append-dependent prefix; preserve its cursor and explicit loss. */ +export function expireActivityPrefix(runId: string): number { + return db.transaction(() => { + let current: ReturnType = null; + try { current = readActivityControl(runId); } + catch (error) { + if (!(error instanceof Error) || error.message !== 'activity_control_corrupt') throw error; + // Keep the corrupt control as a fail-closed tombstone for active owners; + // reclaim its entire runtime prefix without blocking unrelated retention. + console.warn('[activity] expired_corrupt_control'); + } + if (current) writeActivityControl(runId, current.seq, { + ...current.state, count: 0, bytes: 0, closed: true, loss: 'retention', + }); + return db.prepare("DELETE FROM trace_events WHERE run_id = ? AND source = 'runtime'").run(runId).changes; + }).immediate(); +} diff --git a/src/trace/activity-journal.ts b/src/trace/activity-journal.ts new file mode 100644 index 000000000..bdbd7a723 --- /dev/null +++ b/src/trace/activity-journal.ts @@ -0,0 +1,153 @@ +import { db } from '../core/db.js'; +import { settings } from '../core/config.js'; +import type { RuntimeEvent } from '../shared/runtime-contract.js'; +import type { TracePointer, TraceRunStatus } from './types.js'; +import { appendTraceEvent } from './store.js'; +import { stringifyTraceValue } from './redact.js'; +import { decodeRuntimeBody, RUNTIME_BODY_BYTES, type RuntimeBodyRecord } from './runtime-body-codec.js'; +import { ACTIVITY_CONTROL_TYPE, readActivityControl, writeActivityControl, markActivityLoss, + type ActivityControl, type ActivityLoss } from './activity-control.js'; +export { closeActivity, type ActivityLoss } from './activity-control.js'; + +export const ACTIVITY_RUN_ROWS = 4096; +export const ACTIVITY_RUN_BYTES = 4 * 1024 * 1024; +export const ACTIVITY_GLOBAL_ROWS = 20_000; +export const ACTIVITY_GLOBAL_BYTES = 32 * 1024 * 1024; +export const ACTIVITY_PAGE_ROWS = 40; +export const ACTIVITY_PAGE_BYTES = 256 * 1024; + +type OwnerRow = { id: string; session_id: string; scope_key: string | null; + status: TraceRunStatus; audience: 'public' | 'internal' }; +export type ActivityOwner = OwnerRow & { scope_key: string }; +const ownerStmt = db.prepare(`SELECT r.id, r.session_id, r.scope_key, r.status, r.audience + FROM trace_runs r JOIN chat_sessions s ON s.id = r.session_id WHERE r.id = ? AND r.session_id = ?`); +const totals = db.prepare("SELECT COUNT(*) AS count, COALESCE(SUM(bytes), 0) AS bytes FROM trace_events WHERE source = 'runtime'"); +const rowCount = db.prepare('SELECT COUNT(*) AS count FROM trace_events'); +const rowsStmt = db.prepare(`SELECT seq, event_type, bytes, + CASE WHEN length(CAST(raw_json AS BLOB)) <= ${RUNTIME_BODY_BYTES} THEN raw_json ELSE NULL END AS raw_json + FROM trace_events WHERE run_id = ? AND source = 'runtime' AND seq > ? AND seq <= ? ORDER BY seq LIMIT ?`); +const terminalStmt = db.prepare(`SELECT 1 FROM trace_events WHERE run_id = ? AND seq = ? + AND source = 'runtime' AND event_type = 'turn-end'`); +const runsStmt = db.prepare(`SELECT r.id, r.message_id, r.status, r.started_at + FROM trace_runs r JOIN chat_sessions s ON s.id = r.session_id + WHERE r.session_id = ? AND r.audience = 'public' AND length(r.scope_key) BETWEEN 1 AND 240 + AND (? = '' OR r.id > ?) ORDER BY r.id LIMIT ${ACTIVITY_PAGE_ROWS}`); + +export function isTraceSessionOwner(runId: string, sessionId: string): boolean { + const row = ownerStmt.get(runId, sessionId) as OwnerRow | undefined; + return row?.audience === 'public'; +} + +export function getActivityOwner(runId: string, sessionId: string): ActivityOwner | null { + const row = ownerStmt.get(runId, sessionId) as OwnerRow | undefined; + return row?.audience === 'public' && row.scope_key && row.scope_key.length <= 240 + ? { ...row, scope_key: row.scope_key } : null; +} + +export type ActivityRunSummary = { id: string; messageId: number | null; status: TraceRunStatus; startedAt: number }; +export function listActivityRuns(sessionId: string, after = ''): ActivityRunSummary[] { + const rows = runsStmt.all(sessionId, after, after) as { + id: string; message_id: number | null; status: TraceRunStatus; started_at: number; + }[]; + return rows.map(r => ({ id: r.id, messageId: r.message_id, status: r.status, startedAt: r.started_at })); +} + +type AppendInput = { runId: string; sessionId: string; scope: string; audience: 'public' | 'internal'; + eventType: string; raw: RuntimeBodyRecord }; +type AppendOwner = Pick; + +/** Preflight failures must not retire another owner or an already-completed run. */ +export function markActivityFailure(input: AppendOwner, loss: ActivityLoss): void { + try { + db.transaction(() => { + const owner = ownerStmt.get(input.runId, input.sessionId) as OwnerRow | undefined; + if (!owner || owner.scope_key !== input.scope || owner.audience !== input.audience || owner.status !== 'running') return; + const current = readActivityControl(input.runId); + if (current && !current.state.closed) markActivityLoss(input.runId, loss); + }).immediate(); + } catch { console.warn('[activity] failure_metadata_unavailable'); } +} +const append = db.transaction((input: AppendInput): TracePointer | null => { + const owner = ownerStmt.get(input.runId, input.sessionId) as OwnerRow | undefined; + if (!owner || owner.scope_key !== input.scope || owner.audience !== input.audience || owner.status !== 'running') return null; + let current = readActivityControl(input.runId); + if (!current) { + if (input.eventType !== 'turn-start') return null; + const state: ActivityControl = { version: 1, count: 0, bytes: 0, lastSeq: 0, closed: false, loss: null }; + const pointer = appendTraceEvent({ runId: input.runId, source: 'system', eventType: ACTIVITY_CONTROL_TYPE, + raw: state, preview: 'runtime control' }); + if (!pointer) throw new Error('activity_control_write_failed'); + current = { seq: pointer.traceSeq, state }; + } + const c = current.state; + if (c.closed || c.loss) return null; + const bytes = Buffer.byteLength(stringifyTraceValue(input.raw)); + const total = totals.get() as { count: number; bytes: number }; + const allRows = (rowCount.get() as { count: number }).count; + const configuredRows = settings['trace']?.maxRows ?? 50_000; + const loss: ActivityLoss | null = bytes > RUNTIME_BODY_BYTES ? 'event_limit' + : c.count >= ACTIVITY_RUN_ROWS || c.bytes + bytes > ACTIVITY_RUN_BYTES ? 'run_limit' + : total.count >= ACTIVITY_GLOBAL_ROWS || total.bytes + bytes > ACTIVITY_GLOBAL_BYTES + || allRows >= configuredRows ? 'global_limit' : null; + if (loss) { writeActivityControl(input.runId, current.seq, { ...c, loss }); return null; } + const pointer = appendTraceEvent({ runId: input.runId, source: 'runtime', eventType: input.eventType, + raw: input.raw, preview: input.eventType }); + if (!pointer) throw new Error('activity_write_failed'); + writeActivityControl(input.runId, current.seq, { ...c, count: c.count + 1, bytes: c.bytes + bytes, + lastSeq: pointer.traceSeq, closed: input.eventType === 'turn-end' }); + return pointer; +}); + +export function appendActivityBody(input: AppendInput): TracePointer | null { + try { return append.immediate(input); } + catch { + markActivityFailure(input, 'storage_error'); + console.warn('[activity] append_failed'); + return null; + } +} + +export type ActivityPage = { runId: string; sessionId: string; scope: string; status: TraceRunStatus; + events: RuntimeEvent[]; nextAfter: number; through: number; hasMore: boolean; incomplete: boolean; loss: string | null }; +type PageInput = { runId: string; sessionId: string; after: number; through?: number; limit: number }; +const readPage = db.transaction((input: PageInput): ActivityPage | null => { + const owner = getActivityOwner(input.runId, input.sessionId); + if (!owner) return null; + const control = readActivityControl(input.runId); + const high = input.through ?? control?.state.lastSeq ?? 0; + if (high > (control?.state.lastSeq ?? 0) || input.after > high) throw new RangeError('activity_cursor'); + const limit = Math.max(1, Math.min(ACTIVITY_PAGE_ROWS, input.limit)); + const rows = rowsStmt.all(input.runId, input.after, high, limit + 1) as { + seq: number; event_type: string; raw_json: string | null; bytes: number; + }[]; + const page: ActivityPage = { runId: owner.id, sessionId: owner.session_id, scope: owner.scope_key, + status: owner.status, events: [], nextAfter: input.after, through: high, hasMore: false, + incomplete: false, loss: control?.state.loss ?? (control ? null : 'unavailable') }; + // Include identity/envelope overhead; loss/boolean changes fit the reserved bytes. + let used = Buffer.byteLength(JSON.stringify(page)) + 128; + let consumed = 0; + for (const row of rows) { + if (consumed >= limit) break; + let event: RuntimeEvent | null = null; + if (row.raw_json !== null && row.bytes >= 0 && row.bytes <= RUNTIME_BODY_BYTES) { + try { event = decodeRuntimeBody(JSON.parse(row.raw_json), { + version: 1, runId: owner.id, sessionId: owner.session_id, scope: owner.scope_key, seq: row.seq, + }, row.event_type); } catch { /* Corrupt storage advances the scan cursor below. */ } + } + if (!event) { consumed++; page.nextAfter = row.seq; page.loss = 'corrupt'; continue; } + const bytes = Buffer.byteLength(JSON.stringify(event)) + 1; + if (used + bytes > ACTIVITY_PAGE_BYTES) break; + used += bytes; + page.events.push(event); + page.nextAfter = row.seq; + consumed++; + } + page.hasMore = consumed < rows.length; + if (!page.hasMore) page.nextAfter = high; + const missingTerminal = owner.status !== 'running' + && (!control || !terminalStmt.get(owner.id, control.state.lastSeq)); + page.incomplete = page.loss !== null || missingTerminal; + return page; +}); + +export function readActivityPage(input: PageInput): ActivityPage | null { return readPage(input); } diff --git a/src/trace/activity-retention.ts b/src/trace/activity-retention.ts new file mode 100644 index 000000000..9427a2ad3 --- /dev/null +++ b/src/trace/activity-retention.ts @@ -0,0 +1,54 @@ +import { db } from '../core/db.js'; +import { expireActivityPrefix } from './activity-control.js'; + +const rawPredicate = "source <> 'runtime' AND NOT (source = 'system' AND event_type = 'runtime.control.v1')"; +const countRows = db.prepare('SELECT COUNT(*) AS count FROM trace_events'); + +/** Retention never removes an append base from a retained semantic suffix. */ +export function pruneActivityTraceRows(cutoff: number, maxRows: number): { deletedEvents: number; deletedRuns: number } { + return db.transaction(() => { + const before = (countRows.get() as { count: number }).count; + for (const run of db.prepare(`SELECT DISTINCT run_id FROM trace_events + WHERE source = 'runtime' AND created_at < ?`).all(cutoff) as { run_id: string }[]) { + expireActivityPrefix(run.run_id); + } + // A producer may still own an expired, closed projection. Only the trace + // lifecycle status, not control.closed, makes its owner safe to remove. + let deletedRuns = db.prepare(`DELETE FROM trace_runs WHERE started_at < ? AND + (status <> 'running' OR (session_id IS NULL AND scope_key IS NULL AND NOT EXISTS (SELECT 1 FROM trace_events e + WHERE e.run_id = trace_runs.id AND e.source = 'system' AND e.event_type = 'runtime.control.v1')))`) + .run(cutoff).changes; + db.prepare(`DELETE FROM trace_events WHERE created_at < ? AND ${rawPredicate}`).run(cutoff); + const cap = Math.max(0, Math.floor(maxRows)); + let total = (countRows.get() as { count: number }).count; + if (total > cap) { + db.prepare(`DELETE FROM trace_events WHERE rowid IN + (SELECT rowid FROM trace_events WHERE ${rawPredicate} ORDER BY created_at, rowid LIMIT ?)`) + .run(total - cap); + } + total = (countRows.get() as { count: number }).count; + if (total > cap) { + const closed = db.prepare(`SELECT r.id FROM trace_runs r WHERE r.status <> 'running' + AND EXISTS (SELECT 1 FROM trace_events e WHERE e.run_id = r.id AND e.source = 'runtime') + ORDER BY r.started_at, r.id`).all() as { id: string }[]; + for (const run of closed) { + if (total <= cap) break; + total -= expireActivityPrefix(run.id); + } + } + // Tombstones themselves have a finite lifetime under a configured row cap. + // If necessary remove oldest closed owners, preserving active owners even + // when their control has already been closed by expiry. + if (total > cap) { + const closed = db.prepare(`SELECT r.id, COUNT(e.seq) AS count FROM trace_runs r + JOIN trace_events e ON e.run_id = r.id WHERE r.status <> 'running' + GROUP BY r.id ORDER BY r.started_at, r.id`).all() as { id: string; count: number }[]; + for (const run of closed) { + if (total <= cap) break; + deletedRuns += db.prepare('DELETE FROM trace_runs WHERE id = ?').run(run.id).changes; + total -= run.count; + } + } + return { deletedEvents: before - (countRows.get() as { count: number }).count, deletedRuns }; + }).immediate(); +} diff --git a/src/trace/store.ts b/src/trace/store.ts index 3143dcf94..14bf95e82 100644 --- a/src/trace/store.ts +++ b/src/trace/store.ts @@ -5,6 +5,8 @@ import { JAW_HOME } from '../core/config.js'; import { db } from '../core/db.js'; import type { ToolEntry } from '../types/agent.js'; import { stringifyTraceValue, tracePreview } from './redact.js'; +import { closeActivity } from './activity-control.js'; +import { pruneActivityTraceRows } from './activity-retention.js'; import type { TraceAudience, TraceCarrier, TraceEventInput, TracePointer, TraceRetentionStatus, TraceRunInput, TraceRunStatus } from './types.js'; const TRACE_INLINE_MAX_BYTES = 96_000; @@ -17,6 +19,7 @@ type TraceRunRow = { working_dir?: string | null; agent_label?: string | null; audience?: TraceAudience; status?: TraceRunStatus; raw_retention_status?: TraceRetentionStatus; event_count?: number; byte_count?: number; started_at?: number; finished_at?: number | null; error?: string | null; + session_id: string | null; scope_key: string | null; }; type TraceEventRow = { run_id: string; seq: number; source: string; event_type: string; preview?: string | null; @@ -26,8 +29,8 @@ type TraceEventRow = { const insertRun = db.prepare(` INSERT INTO trace_runs - (id, parent_run_id, cli, model, working_dir, agent_label, audience, started_at, last_event_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, parent_run_id, cli, model, working_dir, agent_label, audience, started_at, last_event_at, session_id, scope_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertEvent = db.prepare(` INSERT INTO trace_events @@ -76,10 +79,6 @@ const interruptStaleStmt = db.prepare(` UPDATE trace_runs SET status = 'interrupted', finished_at = ?, error = COALESCE(error, 'process exited before finalization') WHERE status = 'running' `); -const pruneEventsStmt = db.prepare('DELETE FROM trace_events WHERE created_at < ?'); -const pruneRunsStmt = db.prepare('DELETE FROM trace_runs WHERE started_at < ?'); -const countAllEventsStmt = db.prepare('SELECT COUNT(*) AS c FROM trace_events'); -const trimEventsStmt = db.prepare('DELETE FROM trace_events WHERE rowid IN (SELECT rowid FROM trace_events ORDER BY created_at ASC LIMIT ?)'); const liveRunIdsStmt = db.prepare('SELECT id FROM trace_runs'); const seqCache = new Map(); @@ -116,7 +115,8 @@ export function startTraceRun(input: TraceRunInput): string { const id = createTraceId(); const now = Date.now(); insertRun.run(id, input.parentRunId || null, input.cli || 'agent', input.model || null, - input.workingDir || null, input.agentLabel || null, input.audience || 'public', now, now); + input.workingDir || null, input.agentLabel || null, input.audience || 'public', now, now, + input.sessionId ?? null, input.scopeKey ?? null); return id; } @@ -224,6 +224,7 @@ export function stampTraceToolEntries(ctx: TraceCarrier & { toolLog?: ToolEntry[ } export function finalizeTraceRun(runId: string | null | undefined, status: TraceRunStatus, error?: string | null): void { if (!runId || !TRACE_ID_RE.test(runId)) return; + if (status !== 'running') closeActivity(runId); finalizeRunStmt.run(status, Date.now(), error || null, runId); seqCache.delete(runId); } @@ -232,6 +233,9 @@ export function linkTraceRunToMessage(runId: string | null | undefined, messageI linkRunStmt.run(messageId, runId); } export function markStaleTraceRunsInterrupted(): void { + for (const row of db.prepare("SELECT id FROM trace_runs WHERE status = 'running'").all() as { id: string }[]) { + closeActivity(row.id); + } interruptStaleStmt.run(Date.now()); // Interrupted runs never reach finalizeTraceRun, so their seq cursors // stayed in seqCache forever. They receive no further events — the cache @@ -241,10 +245,23 @@ export function markStaleTraceRunsInterrupted(): void { // Remove on-disk spill dirs whose run no longer exists in trace_runs. function pruneOrphanTraceDirs(): void { - if (!fs.existsSync(TRACE_DIR)) return; + if (!fs.existsSync(TRACE_DIR) || !fs.lstatSync(TRACE_DIR).isDirectory()) return; const live = new Set((liveRunIdsStmt.all() as { id: string }[]).map((r) => r.id)); - for (const name of fs.readdirSync(TRACE_DIR)) { - if (TRACE_ID_RE.test(name) && !live.has(name)) fs.rmSync(join(TRACE_DIR, name), { recursive: true, force: true }); + for (const entry of fs.readdirSync(TRACE_DIR, { withFileTypes: true })) { + if (!TRACE_ID_RE.test(entry.name) || !entry.isDirectory()) continue; + const dir = join(TRACE_DIR, entry.name); + if (!live.has(entry.name)) { + fs.rmSync(dir, { recursive: true, force: true }); + seqCache.delete(entry.name); + continue; + } + const retained = new Set((db.prepare('SELECT raw_path FROM trace_events WHERE run_id = ? AND raw_path IS NOT NULL') + .all(entry.name) as { raw_path: string }[]).map(row => resolve(JAW_HOME, row.raw_path))); + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + if (file.isFile() && /^\d{6,}\.json$/.test(file.name) && !retained.has(join(dir, file.name))) { + fs.rmSync(join(dir, file.name)); + } + } } } @@ -252,12 +269,9 @@ function pruneOrphanTraceDirs(): void { export function pruneTraceEvents(retentionDays = 7, maxRows = 50_000): { deletedEvents: number; deletedRuns: number } { try { const cutoff = Date.now() - retentionDays * 86_400_000; - let deletedEvents = pruneEventsStmt.run(cutoff).changes; - const deletedRuns = pruneRunsStmt.run(cutoff).changes; - const total = Number((countAllEventsStmt.get() as { c: number }).c); - if (total > maxRows) deletedEvents += trimEventsStmt.run(total - maxRows).changes; + const result = pruneActivityTraceRows(cutoff, maxRows); pruneOrphanTraceDirs(); - return { deletedEvents, deletedRuns }; + return result; } catch (error) { console.error('[trace] prune failed:', error instanceof Error ? error.message : String(error)); return { deletedEvents: 0, deletedRuns: 0 }; diff --git a/src/trace/types.ts b/src/trace/types.ts index 29d0e80c4..fdcd22bdb 100644 --- a/src/trace/types.ts +++ b/src/trace/types.ts @@ -10,6 +10,8 @@ export interface TraceRunInput { agentLabel?: string | null; audience?: TraceAudience; parentRunId?: string | null; + sessionId?: string; + scopeKey?: string; } export interface TraceEventInput { diff --git a/tests/unit/activity-journal.test.ts b/tests/unit/activity-journal.test.ts new file mode 100644 index 000000000..5aaaace7e --- /dev/null +++ b/tests/unit/activity-journal.test.ts @@ -0,0 +1,314 @@ +import '../setup/isolated-home.ts'; +import test, { beforeEach, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync, symlinkSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import Database from 'better-sqlite3'; +import { db } from '../../src/core/db.js'; +import { settings, JAW_HOME } from '../../src/core/config.js'; +import { recordRuntimeEvent, type RuntimeEventContext } from '../../src/agent/runtime/events.js'; +import { readActivityPage, listActivityRuns, getActivityOwner, isTraceSessionOwner, + ACTIVITY_RUN_ROWS, ACTIVITY_RUN_BYTES, ACTIVITY_GLOBAL_ROWS, ACTIVITY_PAGE_BYTES } from '../../src/trace/activity-journal.js'; +import { closeActivity, readActivityControl, writeActivityControl, expireActivityPrefix } from '../../src/trace/activity-control.js'; +import { startTraceRun, appendTraceEvent, finalizeTraceRun, getTraceRun, getTraceEvent, + pruneTraceEvents, stampTraceTool, updateTraceToolRow } from '../../src/trace/store.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { addBroadcastListener, removeBroadcastListener, broadcast } from '../../src/core/bus.js'; +import { createChatSession, deleteChatSession, forkChatSession } from '../../src/core/chat-sessions.js'; +import type { ToolEntry } from '../../src/types/agent.js'; + +const traceSettings = structuredClone(settings.trace); +beforeEach(() => { db.prepare('DELETE FROM trace_runs').run(); settings.trace = structuredClone(traceSettings); }); +after(() => { db.close(); rmSync(JAW_HOME, { recursive: true, force: true }); }); + +function run(audience: 'public' | 'internal' = 'public', sessionId = 'default'): RuntimeEventContext { + const scope = 'local:' + sessionId; + return { runId: startTraceRun({ cli: 'fixture', audience, sessionId, scopeKey: scope }), + sessionId, scope, turnId: 'turn-1', audience }; +} +function started(audience: 'public' | 'internal' = 'public', sessionId = 'default') { + const context = run(audience, sessionId); + assert.ok(recordRuntimeEvent(context, { kind: 'turn-start', provider: 'fixture' })); + return context; +} +const text = (context: RuntimeEventContext, value = 'hello') => recordRuntimeEvent(context, + { kind: 'message', itemId: 'message', phase: 'unknown', text: value, operation: 'append' }); +const page = (c: RuntimeEventContext, after = 0, through?: number, limit = 40) => + readActivityPage({ runId: c.runId, sessionId: c.sessionId, after, ...(through === undefined ? {} : { through }), limit }); +const count = (runId: string) => (db.prepare("SELECT COUNT(*) AS n FROM trace_events WHERE run_id=? AND source='runtime'").get(runId) as { n: number }).n; + +test('committed noncontiguous sequence, immutable events and latest tool snapshots coexist', () => { + const c = started(); + const first = text(c)!; + const tool: ToolEntry = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'command-1', status: 'running' }; + stampTraceTool(tool, { traceRunId: c.runId }); + const second = text(c, 'world')!; + assert.equal(second.seq, first.seq + 2); + const before = getTraceEvent(c.runId, first.seq)?.raw; + tool.status = 'done'; tool.detail = 'finished'; updateTraceToolRow(tool); + assert.equal(getTraceEvent(c.runId, first.seq)?.raw, before); + assert.deepEqual(page(c)?.events.map(e => e.seq), [2, first.seq, second.seq]); + assert.equal(page(c)?.loss, null); + assert.equal(page(c)?.incomplete, false); +}); + +test('frozen high watermark excludes concurrent tail; small pages advance by scanned cursor', () => { + const c = started(); text(c, 'one'); text(c, 'two'); + const first = page(c, 0, undefined, 1)!; + const tail = text(c, 'later')!; + const rest = page(c, first.nextAfter, first.through)!; + assert.ok(rest.events.every(e => e.seq < tail.seq)); + assert.equal(rest.nextAfter, first.through); + assert.equal(rest.hasMore, false); + assert.equal(page(c, first.through)?.events.at(-1)?.seq, tail.seq); + assert.throws(() => page(c, 0, tail.seq + 1), RangeError); + assert.throws(() => page(c, tail.seq + 1), RangeError); +}); + +test('page UTF-8 byte budget bounds valid multi-byte event streams', () => { + const c = started(); + for (let i = 0; i < 40; i++) assert.ok(text(c, '한'.repeat(8000))); + let cursor = 0, total = 0, through: number | undefined; + do { + const p = page(c, cursor, through)!; + assert.ok(Buffer.byteLength(JSON.stringify({ ok: true, data: p })) <= ACTIVITY_PAGE_BYTES); + assert.ok(p.events.length <= 40); + assert.ok(p.nextAfter > cursor); + total += p.events.length; cursor = p.nextAfter; through = p.through; + if (!p.hasMore) break; + } while (total < 100); + assert.equal(total, 41); +}); + +test('corrupt and oversized rows advance cursor; oversized control is distinct from absence', () => { + const c = started(); const bad = text(c)!; const huge = text(c)!; const good = text(c)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('{bad', c.runId, bad.seq); + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), c.runId, huge.seq); + const p = page(c, bad.seq - 1, undefined, 2)!; + assert.equal(p.events.length, 0); assert.equal(p.nextAfter, huge.seq); + assert.equal(p.loss, 'corrupt'); assert.equal(p.incomplete, true); assert.equal(p.hasMore, true); + assert.equal(page(c, p.nextAfter, p.through)?.events[0]?.seq, good.seq); + const control = readActivityControl(c.runId)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), c.runId, control.seq); + assert.throws(() => page(c), /control_corrupt/); + assert.equal(text(c), null); + assert.doesNotThrow(() => finalizeTraceRun(c.runId, 'done')); + assert.equal(getTraceRun(c.runId)?.status, 'done'); +}); + +test('null, empty and whitespace finals survive; closure is idempotent and late writes stop', () => { + for (const finalText of [null, '', ' \n']) { + const c = started(); + assert.ok(recordRuntimeEvent(c, { kind: 'turn-end', status: 'done', finalText })); + closeActivity(c.runId); finalizeTraceRun(c.runId, 'done'); closeActivity(c.runId); + const p = page(c)!; + const end = p.events.at(-1); + assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, finalText); + assert.equal(p.incomplete, false); assert.equal(text(c), null); + } + const interrupted = started(); text(interrupted, 'partial'); finalizeTraceRun(interrupted.runId, 'interrupted'); + assert.equal(page(interrupted)?.loss, 'storage_error'); + assert.equal(page(interrupted)?.incomplete, true); +}); + +test('owner, scope, start and audience checks block writes and public reads', () => { + const c = run(); assert.equal(text(c), null); + recordRuntimeEvent(c, { kind: 'turn-start', provider: 'fixture' }); + assert.equal(text({ ...c, sessionId: 'missing' }), null); + assert.equal(text({ ...c, scope: 'foreign' }), null); + assert.equal(text({ ...c, audience: 'internal' }), null); + assert.equal(text({ ...c, sessionId: 'missing' }, 'x'.repeat(33_000)), null); + assert.equal(text({ ...c, scope: 'foreign' }, 'x'.repeat(33_000)), null); + assert.equal(page(c)?.loss, null, 'foreign preflight failures cannot mark another owner'); + assert.equal(getActivityOwner(c.runId, 'missing'), null); + const internal = started('internal'); assert.ok(text(internal)); + assert.equal(page(internal), null); + assert.equal(isTraceSessionOwner(internal.runId, internal.sessionId), false); + assert.equal(text({ ...internal, audience: 'public' }), null); + assert.equal(text({ ...internal, audience: 'public' }, 'x'.repeat(33_000)), null); + assert.equal(readActivityControl(internal.runId)?.state.loss, null); + assert.ok(listActivityRuns('default').every(r => r.id !== internal.runId)); + const unowned = startTraceRun({ cli: 'legacy' }); + assert.equal(getTraceRun(unowned)?.session_id, null); + assert.equal(readActivityPage({ runId: unowned, sessionId: 'default', after: 0, limit: 40 }), null); +}); + +test('limits persist a loss instead of truncating an append or emitting a false terminal', () => { + for (const [key, amount] of [['count', ACTIVITY_RUN_ROWS], ['bytes', ACTIVITY_RUN_BYTES]] as const) { + const c = started(); const control = readActivityControl(c.runId)!; + writeActivityControl(c.runId, control.seq, { ...control.state, [key]: amount }); + assert.equal(text(c), null); assert.equal(count(c.runId), 1); assert.equal(page(c)?.loss, 'run_limit'); + } + const huge = started(); assert.equal(text(huge, 'x'.repeat(33_000)), null); + assert.equal(page(huge)?.loss, 'event_limit'); assert.equal(count(huge.runId), 1); + const cap = started(); settings.trace.maxRows = 1; + assert.equal(text(cap), null); assert.equal(page(cap)?.loss, 'global_limit'); +}); + +test('legacy runtime rows count toward global row and byte admission limits', () => { + for (const [rows, raw] of [[ACTIVITY_GLOBAL_ROWS, '{}'], [1100, 'x'.repeat(32_000)]] as const) { + db.prepare('DELETE FROM trace_runs').run(); + const c = started(); const old = startTraceRun({ cli: 'legacy' }); + db.prepare(`WITH RECURSIVE nums(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM nums WHERE n < ?) + INSERT INTO trace_events (run_id,seq,source,event_type,raw_json,bytes,retention_status,created_at) + SELECT ?,n,'runtime','message',?,?,'available',? FROM nums`) + .run(rows, old, raw, Buffer.byteLength(raw), Date.now()); + assert.equal(text(c), null); assert.equal(page(c)?.loss, 'global_limit'); + } +}); + +test('failed control update rolls back runtime insert and publishes nothing', t => { + t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const c = started(); const control = readActivityControl(c.runId)!; + const events: BusEvent[] = []; const unsubscribe = subscribe(e => events.push(e)); + db.exec(`CREATE TRIGGER activity_fail BEFORE UPDATE ON trace_events + WHEN old.source='system' AND old.event_type='runtime.control.v1' + BEGIN SELECT RAISE(ABORT, 'fixture control failure'); END`); + try { + assert.equal(text(c), null); assert.equal(count(c.runId), 1); + assert.equal(readActivityControl(c.runId)?.state.lastSeq, control.state.lastSeq); + assert.equal(events.length, 0); + } finally { db.exec('DROP TRIGGER activity_fail'); unsubscribe(); } + const next = text(c)!; assert.ok(next.seq > control.state.lastSeq + 1, 'rolled-back seq is never published/reused in-process'); +}); + +test('SQL busy and failed insert leave final caller usable with no journal publication', t => { + t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const c = started(); const other = new Database(join(JAW_HOME, 'jaw.db')); + db.pragma('busy_timeout=1'); other.exec('BEGIN IMMEDIATE'); + try { assert.equal(text(c), null); assert.equal(count(c.runId), 1); } + finally { other.exec('ROLLBACK'); other.close(); db.pragma('busy_timeout=5000'); } + db.exec(`CREATE TRIGGER activity_insert_fail BEFORE INSERT ON trace_events WHEN new.source='runtime' + BEGIN SELECT RAISE(ABORT, 'fixture insert failure'); END`); + try { assert.equal(text(c), null); assert.equal(count(c.runId), 1); } + finally { db.exec('DROP TRIGGER activity_insert_fail'); } + assert.equal(page(c)?.loss, 'storage_error'); +}); + +test('retention expires whole prefixes, keeps active owners and eventually reclaims closed metadata', () => { + const admitted = run(); + db.prepare('UPDATE trace_runs SET started_at=0 WHERE id=?').run(admitted.runId); + pruneTraceEvents(7, 0); + assert.ok(getTraceRun(admitted.runId), 'admitted owner survives before first semantic event'); + const c = started(); text(c); const high = page(c)!.through; + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(c.runId); + db.prepare('UPDATE trace_runs SET started_at=0 WHERE id=?').run(c.runId); + pruneTraceEvents(7, 0); + assert.ok(getTraceRun(c.runId)); assert.equal(text(c), null); + assert.deepEqual(page(c)?.events, []); assert.equal(page(c)?.through, high); + assert.equal(page(c)?.nextAfter, high); assert.equal(page(c)?.hasMore, false); + assert.equal(page(c)?.loss, 'retention'); + finalizeTraceRun(c.runId, 'interrupted'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(c.runId), null); + const closed = started(); text(closed); recordRuntimeEvent(closed, { kind: 'turn-end', status: 'done', finalText: 'answer' }); + finalizeTraceRun(closed.runId, 'done'); expireActivityPrefix(closed.runId); + assert.equal(page(closed)?.loss, 'retention'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(closed.runId), null); +}); + +test('retention reclaims obsolete raw spill files within a still-owned run', () => { + const c = started(); + const spill = appendTraceEvent({ runId: c.runId, source: 'cli_raw', eventType: 'large', raw: 'x'.repeat(100_000) })!; + const row = getTraceEvent(c.runId, spill.traceSeq)!; assert.ok(row.raw_path); + const path = join(JAW_HOME, row.raw_path); assert.ok(existsSync(path)); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=? AND seq=?').run(c.runId, spill.traceSeq); + pruneTraceEvents(7); assert.equal(existsSync(path), false); assert.ok(getTraceRun(c.runId)); +}); + +test('corrupt control cannot block unrelated retention or closed owner reclamation', () => { + const corrupt = started(); text(corrupt); + const control = readActivityControl(corrupt.runId)!; + db.prepare('UPDATE trace_events SET raw_json=? WHERE run_id=? AND seq=?').run('x'.repeat(1_000_000), corrupt.runId, control.seq); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(corrupt.runId); + const healthy = started(); text(healthy); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(healthy.runId); + const rawRun = startTraceRun({ cli: 'legacy' }); + const raw = appendTraceEvent({ runId: rawRun, source: 'cli_raw', eventType: 'large', raw: 'x'.repeat(100_000) })!; + const rawPath = join(JAW_HOME, getTraceEvent(rawRun, raw.traceSeq)!.raw_path!); + db.prepare('UPDATE trace_events SET created_at=0 WHERE run_id=?').run(rawRun); + const pruned = pruneTraceEvents(7); + assert.ok(pruned.deletedEvents >= 5); + assert.equal(count(corrupt.runId), 0); assert.ok(getTraceRun(corrupt.runId)); + assert.equal(page(healthy)?.loss, 'retention'); assert.equal(existsSync(rawPath), false); + assert.throws(() => page(corrupt), /control_corrupt/); + finalizeTraceRun(corrupt.runId, 'error'); pruneTraceEvents(7, 0); + assert.equal(getTraceRun(corrupt.runId), null); + assert.ok(getTraceRun(healthy.runId)); +}); + +test('retention refuses a symlinked trace root and preserves an external sentinel', () => { + const c = started(); const root = join(JAW_HOME, 'traces'); const held = join(JAW_HOME, 'traces-held'); + const external = mkdtempSync(join(tmpdir(), 'cli-jaw-activity-external-')); + mkdirSync(join(external, c.runId)); + const sentinel = join(external, c.runId, '999999.json'); writeFileSync(sentinel, 'external sentinel'); + const hadRoot = existsSync(root); if (hadRoot) renameSync(root, held); + symlinkSync(external, root, process.platform === 'win32' ? 'junction' : 'dir'); + try { pruneTraceEvents(7); assert.equal(readFileSync(sentinel, 'utf8'), 'external sentinel'); } + finally { unlinkSync(root); if (hadRoot) renameSync(held, root); rmSync(external, { recursive: true, force: true }); } +}); + +test('fork cannot acquire source journal and deleting the owner removes its trace', () => { + const owner = createChatSession('journal-owner'); const c = started('public', owner.id); text(c); + const fork = forkChatSession(owner.id); + assert.equal(readActivityPage({ runId: c.runId, sessionId: fork.id, after: 0, limit: 40 }), null); + assert.ok(deleteChatSession(owner.id)); assert.equal(getTraceRun(c.runId), null); assert.equal(text(c), null); +}); + +test('semantic records and broadcast defense bypass messaging listeners while legacy final remains', () => { + const legacy: string[] = []; const seen: BusEvent[] = []; + const listener = (type: string) => legacy.push(type); addBroadcastListener(listener); + const unsubscribe = subscribe(e => seen.push(e)); + try { + const c = started(); text(c); + broadcast('agent_runtime_gap', { runId: c.runId, sessionId: c.sessionId, scope: c.scope }); + broadcast('agent_runtime', { runId: 'internal' }, 'internal'); + assert.deepEqual(legacy, []); assert.equal(seen.length, 3); + broadcast('agent_done', { text: 'authoritative legacy final' }); + assert.deepEqual(legacy, ['agent_done']); + } finally { unsubscribe(); removeBroadcastListener(listener); } +}); + +test('another process reads durable journal and marks only stale running traces interrupted', () => { + const c = started(); text(c); + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `const s=await import('./src/trace/store.ts');s.markStaleTraceRunsInterrupted(); + const j=await import('./src/trace/activity-journal.ts'); + console.log('RESULT:'+JSON.stringify(j.readActivityPage({runId:${JSON.stringify(c.runId)},sessionId:'default',after:0,limit:40})));`], + { cwd: process.cwd(), env: process.env, encoding: 'utf8', timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + const result = JSON.parse(child.stdout.split('RESULT:')[1]!); + assert.equal(result.status, 'interrupted'); assert.equal(result.events.length, 2); + assert.equal(result.incomplete, true); assert.equal(text(c), null); +}); + +test('legacy schema migration backfills only original message ownership and remains idempotent', () => { + for (const originalOwner of ['original-owner', 'default']) { + const home = mkdtempSync(join(tmpdir(), 'cli-jaw-activity-migration-')); + const legacy = new Database(join(home, 'jaw.db')); + legacy.exec(`CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, + content TEXT NOT NULL, cli TEXT, model TEXT, trace TEXT, cost_usd REAL, duration_ms INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ${originalOwner === 'default' ? '' : 'session_id TEXT,'} trace_run_id TEXT); + CREATE TABLE trace_runs (id TEXT PRIMARY KEY, message_id INTEGER, parent_run_id TEXT, + cli TEXT NOT NULL, model TEXT, working_dir TEXT, agent_label TEXT, audience TEXT DEFAULT 'public', + status TEXT DEFAULT 'done', raw_retention_status TEXT DEFAULT 'available', event_count INTEGER DEFAULT 0, + byte_count INTEGER DEFAULT 0, started_at INTEGER NOT NULL, finished_at INTEGER, last_event_at INTEGER, error TEXT); + INSERT INTO trace_runs(id,message_id,cli,started_at) VALUES('tr_legacy1234567890',1,'legacy',1); + INSERT INTO messages(id,role,content,trace_run_id) VALUES + (1,'assistant','original','tr_legacy1234567890'), + (2,'assistant','copied','tr_legacy1234567890');`); + if (originalOwner !== 'default') legacy.exec("UPDATE messages SET session_id=CASE WHEN id=1 THEN 'original-owner' ELSE 'fork-owner' END"); + legacy.close(); + try { + for (let i = 0; i < 2; i++) { + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `const {db}=await import('./src/core/db.ts'); + console.log('RESULT:'+JSON.stringify(db.prepare('SELECT session_id,scope_key FROM trace_runs').all()));db.close();`], + { cwd: process.cwd(), env: { ...process.env, CLI_JAW_HOME: home }, encoding: 'utf8', timeout: 10_000 }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout.split('RESULT:')[1]!), [{ session_id: originalOwner, scope_key: null }]); + } + } finally { rmSync(home, { recursive: true, force: true }); } + } +}); diff --git a/tests/unit/activity-routes.test.ts b/tests/unit/activity-routes.test.ts new file mode 100644 index 000000000..480439ac2 --- /dev/null +++ b/tests/unit/activity-routes.test.ts @@ -0,0 +1,303 @@ +import '../setup/isolated-home.ts'; +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { createChatSession, deleteChatSession, forkChatSession, setActiveChatSession } from '../../src/core/chat-sessions.js'; +import { db, insertMessageWithTraceRun } from '../../src/core/db.js'; +import { appendTraceEvent, finalizeTraceRun, getTraceRun, linkTraceRunToMessage, startTraceRun } from '../../src/trace/store.js'; +import { recordRuntimeEvent, type RuntimeEventContext } from '../../src/agent/runtime/events.js'; +import type { RuntimeEventBody } from '../../src/shared/runtime-contract.js'; + +// Keep real SQLite ownership/replay; inject only a repository failure at its public seam. +const journal = await import('../../src/trace/activity-journal.js'); +let storageFailure = false; +mock.module('../../src/trace/activity-journal.js', { + namedExports: { + ...journal, + listActivityRuns: (...args: Parameters) => { + if (storageFailure) throw new Error('private fixture storage path'); + return journal.listActivityRuns(...args); + }, + readActivityPage: (...args: Parameters) => { + if (storageFailure) throw new Error('private fixture storage path'); + return journal.readActivityPage(...args); + }, + }, +}); +const { registerTraceRoutes } = await import('../../src/routes/traces.js'); + +type Auth = (req: Request, res: Response, next: NextFunction) => void; + +async function readResponse(base: string, path: string, status = 200) { + const response = await fetch(base + path, { signal: AbortSignal.timeout(3_000) }); + assert.equal(response.status, status, path); + assert.equal(response.headers.get('cache-control'), 'no-store', path); + const body = await response.json(); + assert.equal(body.ok, status === 200, path); + return body; +} + +async function withServer( + fn: (get: (path: string, status?: number) => ReturnType) => Promise, + auth: Auth = (_req, _res, next) => next(), +): Promise { + const app = express(); + app.set('query parser', 'extended'); + registerTraceRoutes(app, auth); + const server = createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + try { + await fn((path, status = 200) => readResponse(`http://127.0.0.1:${address.port}/api/traces`, path, status)); + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + setActiveChatSession('default'); + } +} + +function ownedRun(sessionId = createChatSession('activity-route-owner').id, audience: 'public' | 'internal' = 'public') { + const scope = `mention-watch:route:${sessionId}`; + const runId = startTraceRun({ cli: 'codex', sessionId, scopeKey: scope, audience }); + const context: RuntimeEventContext = { runId, sessionId, scope, turnId: 'route-turn', audience }; + const emit = (body: RuntimeEventBody) => { + const event = recordRuntimeEvent(context, body); + assert.ok(event, `record ${body.kind}`); + return event; + }; + const start = emit({ kind: 'turn-start', provider: 'codex' }); + return { runId, sessionId, scope, emit, start }; +} + +test('Activity uses owned persisted events, fixed through cursors and exact envelopes', { timeout: 15_000 }, async () => { + const run = ownedRun(); + assert.ok(journal.getActivityOwner(run.runId, run.sessionId)); + assert.equal(journal.getActivityOwner(run.runId, 'wrong-owner'), null); + appendTraceEvent({ runId: run.runId, source: 'cli_raw', eventType: 'gap', raw: { diagnostic: true } }); + const message = run.emit({ kind: 'message', itemId: 'answer', phase: 'final', text: 'original answer', operation: 'replace' }); + const path = `/${run.runId}/activity?session=${run.sessionId}`; + await withServer(async get => { + const first = (await get(path + '&limit=1')).data; + assert.deepEqual(Object.keys(first).sort(), [ + 'runId', 'sessionId', 'scope', 'status', 'events', 'nextAfter', 'through', 'hasMore', 'incomplete', 'loss', + ].sort()); + assert.equal(first.runId, run.runId); + assert.equal(first.sessionId, run.sessionId); + assert.equal(first.scope, run.scope); + assert.equal(first.status, 'running'); + assert.equal(first.through, message.seq); + assert.equal(first.nextAfter, run.start.seq); + assert.equal(first.hasMore, true); + assert.equal(first.incomplete, false); + assert.equal(first.loss, null); + assert.equal(first.events.length, 1); + assert.equal(first.events[0].kind, 'turn-start'); + + const end = run.emit({ kind: 'turn-end', status: 'done', finalText: '' }); + finalizeTraceRun(run.runId, 'done'); + const second = (await get(path + `&after=${first.nextAfter}&through=${first.through}&limit=1`)).data; + assert.equal(second.through, first.through); + assert.equal(second.nextAfter, message.seq); + assert.equal(second.hasMore, false); + assert.deepEqual(second.events.map((event: { kind: string; text?: string }) => [event.kind, event.text]), + [['message', 'original answer']]); + const tail = (await get(path + `&after=${first.through}`)).data; + assert.equal(tail.status, 'done'); + assert.equal(tail.through, end.seq); + assert.equal(tail.hasMore, false); + assert.equal(tail.incomplete, false); + assert.equal(tail.events[0].finalText, ''); + assert.equal(tail.events[0].kind, 'turn-end'); + assert.equal(tail.events[0].sessionId, run.sessionId); + assert.equal(tail.events[0].scope, run.scope); + const empty = (await get(path + `&after=${end.seq}&through=${end.seq}`)).data; + assert.deepEqual(empty.events, []); + assert.equal(empty.nextAfter, end.seq); + assert.equal(empty.hasMore, false); + for (const query of [`&after=${end.seq + 1}`, `&through=${end.seq + 1}`, `&after=${end.seq}&through=${message.seq}`]) { + assert.deepEqual(await get(path + query, 409), { ok: false, error: 'activity_resync_required' }); + } + }); +}); + +test('Activity rejects malformed, repeated, nested, unknown and unsafe query values', { timeout: 15_000 }, async () => { + const run = ownedRun(); + await withServer(async get => { + for (const suffix of ['/activity-runs', `/${run.runId}/activity`]) { + for (const query of ['', '?session=', '?session=a&session=b', '?session[]=a', '?session[x]=a', + '?session=' + 'x'.repeat(241), `?session=${run.sessionId}&unknown=1`, `?session=${run.sessionId}&scope=x`]) { + assert.deepEqual(await get(suffix + query, 400), { ok: false, error: 'invalid_activity_query' }); + } + } + for (const field of ['after', 'through', 'limit']) { + for (const value of ['', '-1', '+1', '1.5', '1e2', '0x10', 'Infinity', 'NaN', '9007199254740992', ' 1']) { + await get(`/${run.runId}/activity?session=${run.sessionId}&${field}=${encodeURIComponent(value)}`, 400); + } + for (const query of [`${field}=1&${field}=2`, `${field}[]=1`, `${field}[x]=1`]) { + await get(`/${run.runId}/activity?session=${run.sessionId}&${query}`, 400); + } + } + for (const limit of ['0', '41']) await get(`/${run.runId}/activity?session=${run.sessionId}&limit=${limit}`, 400); + for (const badRun of ['bad', 'tr_short', 'tr_' + 'a'.repeat(81), 'tr_abcdefghijklmnop!']) { + await get(`/${badRun}/activity?session=${run.sessionId}`, 400); + await get(`/activity-runs?session=${run.sessionId}&after=${badRun}`, 400); + } + for (const query of ['after=a&after=b', 'after[]=x', 'after[x]=x', 'limit=40', 'through=0']) { + await get(`/activity-runs?session=${run.sessionId}&${query}`, 400); + } + // Decimal leading zeroes and an explicitly empty discovery cursor are valid. + await get(`/${run.runId}/activity?session=${run.sessionId}&after=00&limit=01`); + await get(`/activity-runs?session=${run.sessionId}&after=`); + await get('/tr_abcdefghijklmnop/activity?session=unknown', 404); + await get('/tr_' + 'a'.repeat(80) + '/activity?session=unknown', 404); + }); +}); + +test('Activity denies wrong, internal, missing, forked and deleted owners', { timeout: 15_000 }, async () => { + const run = ownedRun(); + const wrong = createChatSession('other-owner').id; + const internal = ownedRun(run.sessionId, 'internal'); + const orphan = ownedRun(); + db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(orphan.sessionId); + assert.ok(getTraceRun(orphan.runId)); + const messageId = Number(insertMessageWithTraceRun.run('assistant', 'fork source', 'codex', '', null, null, '', run.runId, run.sessionId).lastInsertRowid); + linkTraceRunToMessage(run.runId, messageId); + const fork = forkChatSession(run.sessionId); + assert.equal(fork.copiedCount, 1); + await withServer(async get => { + for (const [runId, sessionId] of [[run.runId, wrong], [run.runId, fork.id], [run.runId, 'missing-session'], + [internal.runId, run.sessionId], [orphan.runId, orphan.sessionId]]) { + assert.deepEqual(await get(`/${runId}/activity?session=${sessionId}`, 404), { ok: false, error: 'trace_not_found' }); + } + for (const suffix of ['', '/events', `/events/${orphan.start.seq}`]) { + await get(`/${orphan.runId}${suffix}?session=${orphan.sessionId}`, 404); + } + assert.deepEqual((await get(`/activity-runs?session=${fork.id}`)).data, { runs: [], pageSize: 40 }); + assert.deepEqual((await get('/activity-runs?session=missing-session')).data, { runs: [], pageSize: 40 }); + assert.equal(deleteChatSession(run.sessionId), true); + await get(`/${run.runId}/activity?session=${run.sessionId}`, 404); + assert.deepEqual((await get(`/activity-runs?session=${run.sessionId}`)).data.runs, []); + }); +}); + +test('all raw reads fence either owner column while preserving truly ownerless legacy access', { timeout: 15_000 }, async () => { + const run = ownedRun(); + const raw = appendTraceEvent({ runId: run.runId, source: 'cli_raw', eventType: 'raw', raw: { text: 'owned detail' } }); + assert.ok(raw); + const sessionOnly = startTraceRun({ cli: 'codex', sessionId: run.sessionId }); + const scopeOnly = startTraceRun({ cli: 'codex', scopeKey: run.scope }); + const legacy = startTraceRun({ cli: 'codex' }); + const internal = startTraceRun({ cli: 'codex', audience: 'internal' }); + for (const runId of [sessionOnly, scopeOnly, legacy, internal]) { + assert.ok(appendTraceEvent({ runId, source: 'cli_raw', eventType: 'raw', raw: { text: 'raw detail' } })); + } + assert.equal(journal.getActivityOwner(sessionOnly, run.sessionId), null); + const fork = forkChatSession(run.sessionId); + await withServer(async get => { + for (const [runId, seq] of [[run.runId, raw.traceSeq], [sessionOnly, 1]] as const) { + for (const suffix of ['', '/events', `/events/${seq}`]) { + for (const query of ['', '?session=', '?session=wrong', `?session=${fork.id}`, + `?session=${run.sessionId}&session=${run.sessionId}`, `?session[]=${run.sessionId}`, + `?session[x]=${run.sessionId}`, `?scope=${run.scope}`, `?session=${run.sessionId}%20`]) { + await get(`/${runId}${suffix}${query}`, 404); + } + await get(`/${runId}${suffix}?session=${run.sessionId}`); + } + } + for (const suffix of ['', '/events', '/events/1']) { + await get(`/${scopeOnly}${suffix}?session=${run.sessionId}`, 404); + await get(`/${internal}${suffix}?session=${run.sessionId}`, 404); + await get(`/${legacy}${suffix}`); + await get(`/${legacy}${suffix}?session[]=ignored`); + } + await get(`/${sessionOnly}/activity?session=${run.sessionId}`, 404); + await get(`/${legacy}/activity?session=${run.sessionId}`, 404); + await get(`/${run.runId}/events/invalid?session=${run.sessionId}`, 400); + await get(`/${run.runId}/events/999999?session=${run.sessionId}`, 404); + assert.equal(deleteChatSession(run.sessionId), true); + for (const suffix of ['', '/events', `/events/${raw.traceSeq}`]) await get(`/${run.runId}${suffix}?session=${run.sessionId}`, 404); + }); +}); + +test('Activity event pages default to forty and preserve the cursor across pages', { timeout: 15_000 }, async () => { + const run = ownedRun(); + for (let index = 0; index < 40; index++) { + run.emit({ kind: 'message', itemId: `item-${index}`, phase: 'unknown', text: `text-${index}`, operation: 'replace' }); + } + run.emit({ kind: 'turn-end', status: 'done', finalText: null }); + finalizeTraceRun(run.runId, 'done'); + await withServer(async get => { + const path = `/${run.runId}/activity?session=${run.sessionId}`; + const first = await get(path); + assert.ok(Buffer.byteLength(JSON.stringify(first)) <= 256 * 1024); + assert.equal(first.data.events.length, 40); + assert.equal(first.data.hasMore, true); + const last = (await get(path + `&after=${first.data.nextAfter}&through=${first.data.through}`)).data; + assert.equal(last.events.length, 2); + assert.equal(last.events[0].text, 'text-39'); + assert.equal(last.events[1].kind, 'turn-end'); + assert.equal(last.events[1].finalText, null); + assert.equal(last.nextAfter, first.data.through); + assert.equal(last.hasMore, false); + assert.equal(last.incomplete, false); + }); +}); + +test('literal discovery returns only owned public runs in stable forty-run pages with summary fields', { timeout: 15_000 }, async () => { + const sessionId = createChatSession('discovery-owner').id; + const expected = []; + for (let index = 0; index < 42; index++) { + const run = ownedRun(sessionId); + const messageId = Number(insertMessageWithTraceRun.run('assistant', `answer-${index}`, 'codex', '', null, null, '', run.runId, sessionId).lastInsertRowid); + linkTraceRunToMessage(run.runId, messageId); + run.emit({ kind: 'turn-end', status: 'done', finalText: `answer-${index}` }); + finalizeTraceRun(run.runId, 'done'); + expected.push({ id: run.runId, messageId, status: 'done', startedAt: getTraceRun(run.runId)!.started_at }); + } + expected.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0); + ownedRun(sessionId, 'internal'); + ownedRun(); + startTraceRun({ cli: 'codex', sessionId }); + startTraceRun({ cli: 'codex' }); + await withServer(async get => { + const first = await get(`/activity-runs?session=${sessionId}`); + assert.deepEqual(first, { ok: true, data: { runs: expected.slice(0, 40), pageSize: 40 } }); + const last = await get(`/activity-runs?session=${sessionId}&after=${first.data.runs[39].id}`); + assert.deepEqual(last.data, { runs: expected.slice(40), pageSize: 40 }); + assert.deepEqual((await get(`/activity-runs?session=${sessionId}&after=${last.data.runs[1].id}`)).data, + { runs: [], pageSize: 40 }); + }); +}); + +test('Activity storage failures are no-store 503 envelopes without private error details', { timeout: 10_000 }, async () => { + const run = ownedRun(); + storageFailure = true; + try { + await withServer(async get => { + for (const path of [`/activity-runs?session=${run.sessionId}`, `/${run.runId}/activity?session=${run.sessionId}`]) { + assert.deepEqual(await get(path, 503), { ok: false, error: 'activity_unavailable' }); + } + }); + } finally { + storageFailure = false; + } +}); + +test('every trace route invokes the supplied auth middleware after setting no-store', { timeout: 10_000 }, async () => { + let calls = 0; + const runId = 'tr_abcdefghijklmnop'; + await withServer(async get => { + for (const path of ['/activity-runs?session=owner', `/${runId}/activity?session=owner`, `/${runId}`, `/${runId}/events`, `/${runId}/events/1`]) { + assert.deepEqual(await get(path, 401), { ok: false, error: 'fixture_auth_required' }); + } + }, (_req, res) => { + calls++; + res.status(401).json({ ok: false, error: 'fixture_auth_required' }); + }); + assert.equal(calls, 5); +}); diff --git a/tests/unit/native-acp-callbacks.test.ts b/tests/unit/native-acp-callbacks.test.ts index 62ac6485b..9d4d4d14e 100644 --- a/tests/unit/native-acp-callbacks.test.ts +++ b/tests/unit/native-acp-callbacks.test.ts @@ -14,7 +14,7 @@ import type { RuntimeEvent, RuntimeEventBody } from '../../src/shared/runtime-co const raw: unknown[] = [], published: RuntimeEvent[] = []; let appendFails = false; -mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: (entry: { raw: unknown }) => { +mock.module('../../src/trace/activity-journal.js', { namedExports: { markActivityFailure: () => {}, appendActivityBody: (entry: { raw: unknown }) => { if (appendFails) return null; raw.push(JSON.parse(stringifyTraceValue(entry.raw))); return { traceRunId: 'not-an-identity-source', traceSeq: raw.length, detailAvailable: true, detailBytes: 100, rawRetentionStatus: 'available' }; diff --git a/tests/unit/runtime-event-emitter.test.ts b/tests/unit/runtime-event-emitter.test.ts index 58783cc63..d829fa8e2 100644 --- a/tests/unit/runtime-event-emitter.test.ts +++ b/tests/unit/runtime-event-emitter.test.ts @@ -11,9 +11,12 @@ const publications: Array<{ topic: string; event: string; data: Record { + markActivityFailure: () => {}, + appendActivityBody: (input: { runId: string; eventType: string; raw: unknown }): TracePointer | null => { + const entry: TraceEventInput = { runId: input.runId, source: 'runtime', eventType: input.eventType, + raw: input.raw, preview: input.eventType }; attempts.push(structuredClone(entry)); if (appendMode === 'null') return null; if (appendMode === 'throw') throw new Error('fixture append failure'); From 494aaf083202ca4e0f9703cc17a6bf7f331ba5af Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:00:14 +0900 Subject: [PATCH 08/33] feat: capture chat owners at all trace admissions --- src/agent/spawn.ts | 8 ++++---- tests/unit/pi-spawn-runtime-events.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/agent/spawn.ts b/src/agent/spawn.ts index de7aaa255..f2a963906 100644 --- a/src/agent/spawn.ts +++ b/src/agent/spawn.ts @@ -1735,7 +1735,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...empTag }, traceAudience); if (mainManaged && !opts.internal) beginLiveRun(liveScope, cli); - const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); const ctx: CopilotSpawnContext = { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), @@ -2020,7 +2020,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const piSessionId = isResume && bucketSessionId ? bucketSessionId : ''; console.log(`[jaw:pi] isResume=${isResume}, bucketSessionId=${bucketSessionId || 'none'}, piSessionId=${piSessionId || 'new'}`); const piPrompt = withSteerContext(piSessionId ? prompt : withHistoryPrompt(prompt, historyBlock), opts.steerContext); - const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); const ctx: SpawnContext = { fullText: '', traceLog: [], @@ -2314,7 +2314,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { } if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...empTag }, traceAudience); - const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); const ctx: CopilotSpawnContext = { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), @@ -3015,7 +3015,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (!opts.internal) broadcast('agent_status', { status: 'running', cli, agentId: agentLabel, ...runtimeStatusMeta, ...empTag }, traceAudience); - const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience }); + const traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: settings["workingDir"] || null, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); if (mainManaged && !opts.internal) setLiveRunTraceId(liveScope, traceRunId); // Native `agy --conversation ... -p` may emit only the current answer. // Length-based replay trimming can therefore swallow the whole new answer. diff --git a/tests/unit/pi-spawn-runtime-events.test.ts b/tests/unit/pi-spawn-runtime-events.test.ts index c624f75cc..77e0db4e3 100644 --- a/tests/unit/pi-spawn-runtime-events.test.ts +++ b/tests/unit/pi-spawn-runtime-events.test.ts @@ -97,6 +97,8 @@ test.mock.module('../../src/agent/watchdog.js', { namedExports: { ...watchdog, attachWatchdog: () => ({ markProgress() {}, extendDeadline() {}, stop() { fixture.watchdogStops++; } }), } }); const traces = await import('../../src/trace/store.ts'); +const { db } = await import('../../src/core/db.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); const live = await import('../../src/agent/live-run-state.ts'); const lifecycle = await import('../../src/agent/lifecycle-handler.ts'); test.mock.module('../../src/agent/lifecycle-handler.js', { namedExports: { @@ -120,6 +122,7 @@ const publicEvents: string[] = []; let unsubscribe = () => {}; test.beforeEach(() => { + db.prepare("INSERT OR IGNORE INTO chat_sessions(id,seq,label) VALUES('jaw-chat-id',9001,'Pi owned fixture')").run(); fixture.mode = 'ok'; fixture.calls.length = 0; fixture.acquisitions.length = 0; fixture.contexts.length = 0; fixture.events.length = 0; fixture.lifecycle.length = 0; fixture.legacy.length = 0; fixture.direct = 0; fixture.releases = 0; fixture.watchdogStops = 0; publicEvents.length = 0; @@ -144,6 +147,13 @@ function opts(employee = false) { } function assertCanonicalContext(employee: boolean) { assert.ok(fixture.events.length > 0, 'real spawn must feed the shared runtime emitter'); + const runId = fixture.events[0]!.runId; + const owner = traces.getTraceRun(runId); + assert.equal(owner?.session_id, 'jaw-chat-id'); + assert.equal(owner?.scope_key, 'pi-test-scope'); + const replay = readActivityPage({ runId, sessionId: 'jaw-chat-id', after: 0, limit: 40 }); + if (employee) assert.equal(replay, null, 'internal trace stays private despite captured owner'); + else assert.deepEqual(replay?.events, fixture.events, 'actual spawn, emitter, stored codec and replay agree'); for (const context of fixture.contexts) { assert.equal(context.sessionId, 'jaw-chat-id'); assert.equal(context.scope, 'pi-test-scope'); assert.equal(context.parentItemId, 'jaw-parent-item'); From 97001588bf7f76461ad84207fd99c0c1f652adde Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:01:46 +0900 Subject: [PATCH 09/33] docs: document Activity replay ownership and retention --- AGENTS.md | 2 ++ CLAUDE.md | 2 ++ README.md | 2 ++ structure/AGENTS.md | 2 ++ structure/INDEX.md | 6 ++++++ structure/infra.md | 7 +++++++ structure/runtime-integration.md | 21 +++++++++++++++++++++ structure/server_api.md | 20 ++++++++++++++++++-- structure/str_func.md | 20 ++++++++++++-------- structure/stream-events.md | 8 ++++++++ 10 files changed, 80 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a8a4a4e5..12746a8e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,8 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Native decisions +Activity journal and raw trace routes share exact chat ownership for every owned row, including historical backfills. Forked message pointers do not grant access. Runtime rows are immutable and bounded; whole-prefix retention reports loss, protects active owners and cannot be disabled by one corrupt control. Journal failure must not interrupt final delivery or MESSAGE salvage. See `structure/runtime-integration.md` and `structure/server_api.md`. + Activity display is selected by `presentation.mode` (`activity` default, explicit `legacy` retained), separately from provider transport. Snapshot `GET /api/orchestrate/snapshot?session=...` supplies captured `activityIdentity={sessionId,scope}`; clients validate it before semantic admission. Presentation-only settings writes must not reset fallback state or synchronize execution configuration. Existing instance auth and disabled multi-session resolver policy remain. `src/agent/runtime/requests.ts` and `acp/callbacks.ts` own bounded pending decisions, opaque native-option mapping and cancellation latches. `GET /api/runtime/requests?sessionId=...` and `POST /api/runtime/requests/:id` use the existing instance auth policy (including loopback/LAN bypass), never a current-session fallback. Match run/session/scope/turn and current ownership before answering. Canonical sanitization and the32KiB event preflight precede insertion; global128/120s and per-connection32 bounds apply. An admitted selected write cannot be retracted: cancellation during dispatch retires the connection. Provider activation, approval UI and messaging changes are not implied. Sync `structure/runtime-integration.md` and `structure/server_api.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 6f85b7d8e..7edaf2a67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc ## Documentation Map +- Activity journal reuses immutable runtime trace rows and nullable admission owners. Replay and raw owned trace reads require exact chat ownership; forks do not inherit access. Whole-prefix retention preserves explicit loss while active owners remain protected. Journal failure cannot gate final delivery or interrupted MESSAGE salvage. + - Activity uses `presentation.mode` (`activity` by default, reversible `legacy`) independently of provider transport. `GET /api/orchestrate/snapshot?session=...` returns server-owned `activityIdentity`; validate it before semantic admission. Display-only writes preserve runtime selection and delivery. See `structure/runtime-integration.md`. - Start at `structure/INDEX.md` for the current architecture map. diff --git a/README.md b/README.md index 1feb6d007..bc6fe2a25 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Conversation display uses `presentation.mode`: `activity` by default, or `legacy the previous transcript view. This preference is independent of provider transport. Activity clients obtain their chat identity from the server snapshot before subscribing to semantic updates; see [runtime integration](structure/runtime-integration.md). +Activity history is retained in the bounded trace journal and replayed with a fixed +cursor. Owned raw trace requests also carry their captured chat session.
Safe install — for existing users who want minimal changes diff --git a/structure/AGENTS.md b/structure/AGENTS.md index 397a46428..fe843bf89 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -2,6 +2,8 @@ # structure/ — Sync Guide +- Journal changes synchronize nullable trace ownership/backfill, strict replay/raw reads, whole-prefix retention and caller session capture. Keep immutable runtime rows distinct from mutable tool/control rows; finalization uses the DB-only control leaf. Source ownership and limits are documented in `runtime-integration.md`. + - Activity identity and display settings: `shared/presentation.ts`, config/settings-merge, runtime-settings and orchestrate snapshot share the contract in `runtime-integration.md` and `server_api.md`. Mode is independent of transport; snapshot identity is server-owned, including when multi-session is disabled. - Keep this folder aligned with the live `cli-jaw` tree. The current hub covers 19 Markdown docs plus 5 support files. diff --git a/structure/INDEX.md b/structure/INDEX.md index 0af46381c..c8d92a58e 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -6,6 +6,12 @@ aliases: [CLI-JAW Architecture Reference, cli-jaw 구조 허브, structure index # CLI-JAW Architecture Reference +Activity backend: `shared/presentation.ts` owns display/identity decoding; +`trace/activity-journal.ts` owns bounded durable replay on existing trace storage. +`activity-control.ts` and `activity-retention.ts` keep finalization and pruning independent +of runtime projections. Snapshot/trace contracts are in `server_api.md`; ownership, +loss and delivery separation are in `runtime-integration.md` and `stream-events.md`. + > cli-jaw 프로젝트의 내부 구조를 기술한 아키텍처 문서 허브. 시스템 전체 흐름부터 개별 모듈까지, 이 파일에서 시작하세요. > > Planning state lives separately under `devlog/_plan/README.md`. The latest GitHub-issue triage snapshot (2026-05-16) is in that file's "Triage Snapshot" section. diff --git a/structure/infra.md b/structure/infra.md index c54490d40..c037a17e6 100644 --- a/structure/infra.md +++ b/structure/infra.md @@ -8,6 +8,13 @@ aliases: [CLI-JAW Infra, infrastructure modules, core runtime] # 인프라 모듈 — core/ · messaging/ · telegram/ · discord/ · memory/ · browser/ · routes/ · security/ · http/ · lib/mcp-sync +Activity storage extends trace_runs with nullable session/scope owners. Its original +message-link backfill runs after messages.session_id migration. `activity-control.ts` +is a DB-only leaf shared by journal, finalization and retention; `activity-retention.ts` +expires whole runtime prefixes and protects active owners. One corrupt control cannot +roll back unrelated retention. Symlink trace roots are never traversed for spill cleanup. +See `runtime-integration.md` for budgets and loss semantics. + > 의존 0 모듈 + 데이터 레이어 + 멀티 채널 메시징 + 외부 도구 통합 > 현재 tree 기준으로 `src/core/`는 support cluster, `src/messaging/`는 Telegram/Discord 공통 런타임, `src/telegram/`·`src/discord/`는 각 채널 transport 구현으로 분리됨 diff --git a/structure/runtime-integration.md b/structure/runtime-integration.md index dbc699cc8..a590d3bd9 100644 --- a/structure/runtime-integration.md +++ b/structure/runtime-integration.md @@ -23,6 +23,27 @@ semantic admission and never derive a native session ID or scope from UI state. `parseRuntimeRequestView` exposes the existing request-view validator without widening the RuntimeEvent schema. Snapshot responses are no-store; auth remains instance-level. +Activity history uses the existing trace allocator and immutable `source=runtime` rows. +Nullable `trace_runs.session_id/scope_key` capture the jaw chat and execution scope at +admission, including internal workers. Historical backfill uses only the original +`trace_runs.message_id` link and leaves unknown scopes null; copied fork pointers do +not grant access. Deleting a chat removes its owned traces. Clearing messages alone +does not securely erase retained trace history. + +`trace/activity-journal.ts` commits a bounded body and one mutable control row atomically +before SSE publication. Limits are32KiB/body,4096 rows/4MiB/run,20000 rows/32MiB global, +plus configured trace row admission. Loss closes projection admission without interrupting +existing final delivery or MESSAGE salvage. Internal audience stays private. The DB-only +control/retention modules avoid a store-to-journal import cycle; finalization closes +metadata best effort. Corrupt control reads are bounded and cannot stop unrelated pruning. + +Replay captures a fixed through cursor and scans at most40 events/256KiB per page. +Sequence gaps are valid; corrupt rows advance nextAfter and mark incomplete. Whole-prefix +retention preserves a loss watermark; active owners survive even after projection expiry. +Closed metadata may be evicted entirely under row pressure. Raw spill cleanup refuses +symlink roots and child links. Missing journals never justify retrying inference or sending +another answer. Replay request views are historical and non-actionable. + `src/shared/runtime-contract.ts` defines native/print capabilities, distinct native-input/cancel-reprompt/queued/restart controls, and versioned presentation events. A jaw chat session and routing scope are separate from private provider session IDs. `RuntimeTurnOutcome` keeps authoritative `finalText` (null means absent; an empty string is intentional) separate from partial text. `src/agent/runtime/events.ts` records a validated, redacted body through the existing trace writer before publishing `agent_runtime` on the agent event topic. The trace writer owns sequence allocation; sequence gaps are valid. The tuple codec in `src/trace/runtime-body-codec.ts` preserves numeric usage without weakening raw-trace secret masking. Known structured fragments must be sanitized before clipping by their producer. Recording failure returns null, never a fabricated event or another inference. diff --git a/structure/server_api.md b/structure/server_api.md index 0efc01476..d462e69ca 100644 --- a/structure/server_api.md +++ b/structure/server_api.md @@ -16,6 +16,22 @@ Disabled multi-session preserves the existing active-chat/default-scope policy. Responses are no-store. `presentation.mode` is `activity` by default or explicit `legacy`, independent of transport; PUT `/api/settings` validates and merges it. +Activity discovery returns `{ok:true,data:{runs,pageSize:40}}` from +`GET /api/traces/activity-runs?session=&after=`. Each run is +`{id,messageId,status,startedAt}`; IDs are opaque ascending cursors, not time order. +Reconnect discovery starts again from the beginning. Replay is +`GET /api/traces/:runId/activity?session=&after=&through=&limit=40` +with `{ok:true,data:{runId,sessionId,scope,status,events,nextAfter,through,hasMore,incomplete,loss}}`. +The initial page chooses through; later pages hold it fixed. Limits are40 rows/256KiB; +corrupt rows advance the cursor and mark incomplete. Unknown query keys, non-scalar +selectors or invalid cursors return400; future cursors409; unavailable storage503. +Wrong/internal/deleted/fork ownership returns404. Discovery for an unknown chat is empty. + +All trace responses are no-store. Raw summary/list/detail also require `session` when +either owner column exists, including historical session-only backfills. Truly ownerless +legacy raw traces retain instance-level access. Clients capture the owner at drawer open +and reuse it for every request. Scope and copied message pointers do not grant access. + > Express/SSE bootstrap + localhost/LAN opt-in 보안 가드 + `src/routes/*` registrar + mounted sub-router 등록. > Route-module inventory and the endpoint contracts below describe the surface; aggregate handler counts are not maintained by hand. > mutation route(`POST`/`PUT`/`DELETE`)는 모두 `requireAuth`를 거친다. 단, `requireAuth()`는 loopback 요청을 토큰 없이 통과시키고, `lanAllowed()`가 true일 때 private IP도 LAN bypass로 통과시킨다. @@ -49,7 +65,7 @@ Responses are no-store. `presentation.mode` is `activity` by default or explicit | `src/routes/employees.ts` | 123L | 5 | employee CRUD + reset | | `src/routes/skills.ts` | 89L | 5 | skills list/read/enable/disable/reset | | `src/routes/avatar.ts` | 146L | 4 | avatar summary + agent/user image upload/delete/read | -| `src/routes/traces.ts` | 80L | 3 | public trace summary/event read routes | +| `src/routes/traces.ts` | 167L | 5 | owner-bound raw traces and bounded Activity discovery/replay | | `src/routes/runtime-requests.ts` | 33L | 2 | exact-bound ephemeral native decisions; existing instance auth | | `src/routes/link-preview.ts` | 319L | 2 | Rich link preview metadata fetch + guarded image proxy | | `src/routes/heartbeat.ts` | 289L | 4 | heartbeat GET + validated PUT + mention-watch hold read/fresh-start | @@ -166,7 +182,7 @@ Cursor/Grok activation and Activity controls are separate from this API foundati | Messaging | `POST /api/upload` `POST /api/file/open` `POST /api/voice` `POST /api/telegram/send` `POST /api/channels/validate` `POST /api/channel/send` `POST /api/discord/send` `POST /api/slack/send` `GET /api/slack/history` `GET /api/slack/members` `GET /api/slack/users` | | Wiki | `GET /api/wiki/status` `GET /api/wiki/entities` `POST /api/wiki/enable` `POST /api/wiki/configure` | | Avatar | `GET /api/avatar` `POST /api/avatar/:target/upload` `DELETE /api/avatar/:target/image` `GET /api/avatar/:target/image` | -| Traces | `GET /api/traces/:runId` `GET /api/traces/:runId/events` `GET /api/traces/:runId/events/:seq` | +| Traces | `GET /api/traces/activity-runs?session=...&after=...` `GET /api/traces/:runId/activity?session=...&after=...&through=...&limit=40` `GET /api/traces/:runId` `GET /api/traces/:runId/events` `GET /api/traces/:runId/events/:seq` | | Debug | `GET /api/debug/mem` | | Link Preview | `GET /api/link-preview?url=` `GET /api/link-preview/image?url=` | | Dashboard Board | `GET /api/dashboard/board/tasks` `POST /api/dashboard/board/tasks` `PATCH /api/dashboard/board/tasks/:id` `DELETE /api/dashboard/board/tasks/:id` `POST /api/dashboard/board/tasks/from-message` | diff --git a/structure/str_func.md b/structure/str_func.md index 5a43ffa74..c6b5744f6 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -43,11 +43,11 @@ cli-jaw/ │ │ ├── compact.ts ← compact 헬퍼 (COMPACT_MARKER_CONTENT, managed summary builder, cutoff logic, harvestGitGrep + harvestChatGrep 1KB/1KB budget split) (784L) │ │ ├── instance.ts ← 인스턴스 ID, node/jaw 경로, 유닛명 sanitize (61L) │ │ ├── session-generation.ts ← persistent chat_sessions.generation (not process-local spawn tokens) (94L) -│ │ ├── db.ts ← SQLite 스키마 + prepared statements + trace + tool_log + working_dir migration + closeDb() WAL checkpoint + checkOrphanedWal + busy_timeout + clearMessagesScoped + queued_messages table + model-aware clearEmployeeSession + getRecentMessagesLite + searchMessages(days+recent scope) + getMessageContext(±N range) (1156L) +│ │ ├── db.ts ← SQLite 스키마 + prepared statements + trace + tool_log + working_dir migration + closeDb() WAL checkpoint + checkOrphanedWal + busy_timeout + clearMessagesScoped + queued_messages table + model-aware clearEmployeeSession + getRecentMessagesLite + searchMessages(days+recent scope) + getMessageContext(±N range) (1171L) │ │ ├── db-maintenance.ts ← legacy tool_log 재살균 1회 마이그레이션(schema_migrations 마커) + page/freelist 통계 + checkpoint+VACUUM (`jaw db maintain`) (49L) -│ │ ├── chat-sessions.ts ← 채팅 세션 CRUD + 활성 세션 전환 (233L) +│ │ ├── chat-sessions.ts ← 채팅 세션 CRUD + 활성 세션 전환 (234L) │ │ ├── rate-limit.ts ← 클라이언트 클래스별(cli/manager/browser/lan/remote) 슬라이딩 윈도 리미터 + atomic peek/commit + Retry-After 미들웨어 팩토리 (217L) -│ │ ├── bus.ts ← public SSE publish + 내부 리스너 fan-out (70L) +│ │ ├── bus.ts ← public SSE publish + 내부 리스너 fan-out (76L) │ │ ├── logger.ts ← 로거 유틸 + structured log.event (100L) │ │ ├── i18n.ts ← 서버사이드 번역 (90L) │ │ ├── employees.ts ← Employee 시드/CRUD 공용 로직 + 정적 직원 등록(Control: codex `gpt-5.6-luna` + `codex-imagegen`) + virtual synthetic row/preset helpers + DEFAULT_EMPLOYEES (437L) @@ -88,7 +88,7 @@ cli-jaw/ │ │ │ ├── codex-projection.ts ← owned Codex notification mapping (98L) │ │ │ ├── outcome.ts ← non-journal native result handoff and stop precedence (31L) │ │ │ ├── session.ts ← native session/turn/control port (23L) -│ │ │ └── events.ts ← validated trace-first semantic emitter (39L) +│ │ │ └── events.ts ← validated trace-first semantic emitter (43L) │ │ ├── spawn.ts ← CLI spawn + ACP/Codex App/Pi RPC/AGY/Kiro plain text/log session capture/claude-e helper 분기 + v2 SQLite session resume + 큐 + 메모리 flush + 429 retry timer + isAgentBusy/isSteerInProgress + buildHistoryBlock compact cutoff + working_dir scoping + enqueue→processQueue race fix + QueueItem persistent DB queue + makeCleanEnv PATH augment (3593L) │ │ ├── spawn/ ← spawn 서브모듈 (3 files) │ │ │ ├── queue.ts ← QueueItem persistent DB queue + processQueue race fix + enqueue/dequeue + drainRecoveredQueue (부팅 시 복구 큐 기동, server.ts가 transport 준비 후 호출) + `_fromQueue` 표식 (대기자 없는 턴을 채널이 답할 수 있게) (689L) @@ -359,7 +359,7 @@ cli-jaw/ │ │ ├── goal-run.ts ← goal-run execution routes (83L) │ │ ├── runtime-context.ts ← runtime context route helpers (46L) │ │ ├── security-audit.ts ← security audit route registrar (18L) -│ │ ├── traces.ts ← public trace summary/events read routes (80L) +│ │ ├── traces.ts ← public trace summary/events read routes (167L) │ │ ├── runtime-requests.ts ← exact-bound ephemeral native decision GET/POST (33L) │ │ └── browser.ts ← 브라우저 API 라우트 + `cdpPort(req)` 포트 우선순위 + primitive/tab/debug/doctor/cleanup/web-ai routes (489L) │ ├── security/ ← 보안 입력 검증 (4 files) @@ -391,17 +391,21 @@ cli-jaw/ │ │ ├── failure-matrix.ts ← goal-run failure classification (37L) │ │ ├── policy.ts ← goal-run preflight gates + budget check (56L) │ │ └── types.ts ← GoalRunMode, GoalRunBudget, GoalRunSafetyGate, GoalRunState 타입 (36L) -│ ├── trace/ ← Trace 이벤트 영속화 (5 files) +│ ├── trace/ ← Trace 이벤트 영속화 (8 files) +│ │ ├── activity-journal.ts ← bounded Activity append, owner gate and fixed-through replay (153L) +│ │ ├── activity-control.ts ← bounded control metadata and best-effort closure (75L) +│ │ ├── activity-retention.ts ← whole-prefix expiry and protected active owners (54L) │ │ ├── runtime-body-codec.ts ← canonical runtime body tuples + contextual redaction (144L) -│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (336L) +│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (350L) │ │ ├── retention.ts ← startTraceRetention: boot prune + 6h sweep, {stop(), stopped} 핸들 (server.ts shutdown 이 소유) (22L) -│ │ ├── types.ts ← TraceRunInput, TraceEventInput, TracePointer, TraceRunRow 타입 (36L) +│ │ ├── types.ts ← TraceRunInput, TraceEventInput, TracePointer, TraceRunRow 타입 (38L) │ │ └── redact.ts ← trace event redaction helpers (48L) │ ├── shared/ ← 공유 유틸리티 (6 files + reminders helper) ✨ │ │ ├── elicitation-spec.ts ← structured elicitation schema + validation helper (167L) │ │ ├── runtime-observability.ts ← worker-run/background-task shared runtime status category vocabulary (40L) │ │ ├── runtime-contract.ts ← native session capabilities, turn outcome and presentation event types (50L) │ │ ├── runtime-event-parse.ts ← versioned presentation boundary decoder (92L) +│ │ ├── presentation.ts ← display mode accessor and server identity decoder (24L) │ │ ├── shell-command-display.ts ← shell command display sanitization helper (48L) │ │ ├── structured-fence.ts ← structured renderer fence scanner/parser helper (80L) │ │ ├── tool-log-sanitize.ts ← tool log sanitization helpers (247L) diff --git a/structure/stream-events.md b/structure/stream-events.md index e2de65151..c1cb6e080 100644 --- a/structure/stream-events.md +++ b/structure/stream-events.md @@ -6,6 +6,14 @@ aliases: [CLI Stream Event Reference, stream events, SSE event channel, NDJSON p # CLI Stream Event Reference (SSE + Legacy WS + Provider Streams) +Activity events are committed to the bounded trace journal before direct SSE publication. +`agent_runtime` and `agent_runtime_gap` bypass messaging/collect listeners, including the +defense in `broadcast`. Runtime seq is monotonic but noncontiguous because raw/tool/control +rows share the allocator. Reconnect clients refresh server identity and durable replay +even if the in-memory SSE ring still accepts a cursor. Loss never substitutes a final or +triggers another send; replay requests are historical. Contracts and limits are in +`runtime-integration.md` and `server_api.md`. + > 각 CLI의 NDJSON/ACP/stream-json 이벤트를 `src/agent/events/`가 파싱하고, AGY plain-text output은 `spawn.ts`가 직접 처리한다. X-01 이후 current server의 public Web delivery는 `src/core/event-bus.ts` + `GET /api/events` SSE channel이 담당한다. WebSocket은 current server broadcast path가 아니라 `/api/events`가 한 번도 열리지 않는 pre-X-01 server용 client/TUI fallback이다. > 마지막 코드 대조: 2026-06-27 (`src/core/event-bus.ts`, `src/agent/lifecycle-handler.ts`, `src/goal/heartbeat.ts`, `src/agent/events/claude.ts`, `public/js/features/process-block.ts`, `public/js/ws.ts`) From 29c86dfcf4e0a8c45190659e884e5f2866686f73 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:02:18 +0900 Subject: [PATCH 10/33] docs: reconcile backend and parent evidence ancestry --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index 3b57282ff..7dcda5181 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit 3b57282ff32cbe5ec157145c92418d349ab1f547 +Subproject commit 7dcda5181d293a92a019743d9ac4d5821978af27 From f0b786799677950befae764e0e08ea5d521b780d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:03:21 +0900 Subject: [PATCH 11/33] fix(web): capture trace session across all drawer reads --- public/js/features/process-block.ts | 14 +++- public/js/features/trace-drawer.ts | 35 +++++--- tests/unit/trace-drawer-runtime.test.ts | 102 +++++++++++++++++++++++- 3 files changed, 136 insertions(+), 15 deletions(-) diff --git a/public/js/features/process-block.ts b/public/js/features/process-block.ts index 8f7900b0c..886e957ae 100644 --- a/public/js/features/process-block.ts +++ b/public/js/features/process-block.ts @@ -1,5 +1,8 @@ import { escapeHtml } from '../render.js'; import { ICONS } from '../icons.js'; +import { api } from '../api.js'; +import { parseActivityIdentity } from '../../../src/shared/presentation.js'; +import { withCurrentSessionQuery } from './session-hub.js'; import { displayShellCommand, displayShellCommandDetail, @@ -51,6 +54,7 @@ const PROCESS_DETAIL_COLLAPSE_CLEAR_CHARS = 1000; const PROCESS_BLOCK_MAX_RENDERED_STEPS = 80; const PROCESS_BLOCK_HEAD_STEPS = 24; const PROCESS_BLOCK_TAIL_STEPS = 24; +let traceOpenIntent = 0; export interface StoredProcessStepMeta { id: string; @@ -421,7 +425,15 @@ export function bindProcessBlockInteractions(root: HTMLElement): void { event.stopPropagation(); const runId = traceTrigger.dataset['traceRunId'] || ''; const seq = Number(traceTrigger.dataset['traceSeq'] || 0); - import('./trace-drawer.js').then(m => m.openTraceDrawer(runId, seq)) + const intent = ++traceOpenIntent; + // Capture the selected query before the lazy import; the server resolves + // disabled/default sessions too. A failed read never invents an owner. + const snapshot = api<{ activityIdentity?: unknown }>(withCurrentSessionQuery('/api/orchestrate/snapshot')); + Promise.all([import('./trace-drawer.js'), snapshot]) + .then(([m, data]) => { + if (intent !== traceOpenIntent || !traceTrigger.isConnected) return; + return m.openTraceDrawer(runId, seq, parseActivityIdentity(data?.activityIdentity)?.sessionId ?? null); + }) .catch(error => console.warn('[trace-drawer] open failed:', error)); return; } diff --git a/public/js/features/trace-drawer.ts b/public/js/features/trace-drawer.ts index 9b3f553fe..621e3e9fc 100644 --- a/public/js/features/trace-drawer.ts +++ b/public/js/features/trace-drawer.ts @@ -20,15 +20,18 @@ let totalCount = 0; let loading = false; let openRequestId = 0; let selectedSeq: number | null = null; +let traceSession: string | null = null; +let traceController: AbortController | null = null; +let returnFocus: HTMLElement | null = null; function eventTypeOf(event: TraceEventListItem): string { return event.eventType || event.event_type || 'event'; } function isCurrentRequest(requestId: number, runId = currentRunId): boolean { return requestId === openRequestId && runId === currentRunId; } -function requestedOffset(seq?: number): number { - if (!seq || !Number.isInteger(seq) || seq < 1) return 0; - return Math.floor((seq - 1) / PAGE_SIZE) * PAGE_SIZE; +function tracePath(path: string): string { + if (!traceSession) return path; + return path + (path.includes('?') ? '&' : '?') + new URLSearchParams({session:traceSession}); } function ensureDrawer(): HTMLElement { @@ -74,7 +77,14 @@ function setRaw(text: string): void { const raw = document.getElementById('traceEventRaw'); if (raw) raw.textContent = text; } -function closeTraceDrawer(): void { document.getElementById('traceDrawerOverlay')?.classList.remove('open'); } +export function closeTraceDrawer(): void { + ++openRequestId; + traceController?.abort(); traceController = null; + loading = false; + document.getElementById('traceDrawerOverlay')?.classList.remove('open'); + if (returnFocus?.isConnected) returnFocus.focus({preventScroll:true}); + returnFocus = null; +} function renderSummary(summary: TraceSummary): void { const title = document.getElementById('traceDrawerTitle'); @@ -121,9 +131,9 @@ function renderEventRows(events: TraceEventListItem[], runId: string): void { async function loadNextPage(requestId = openRequestId, runId = currentRunId, offset = loadedCount): Promise { if (!runId || loading || (loadedCount >= totalCount && totalCount > 0 && offset >= loadedCount)) return; loading = true; - const page = await api(`/api/traces/${encodeURIComponent(runId)}/events?offset=${offset}&limit=${PAGE_SIZE}`); - loading = false; + const page = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}/events?offset=${offset}&limit=${PAGE_SIZE}`), {signal:traceController?.signal ?? null}); if (!isCurrentRequest(requestId, runId)) return; + loading = false; if (!page) { if (!selectedSeq) setRaw('Trace events could not be loaded.'); return; @@ -139,15 +149,19 @@ async function loadEventDetail(runId: string, seq: number, requestId = openReque if (!runId || !Number.isInteger(seq) || seq < 1) return; if (!isCurrentRequest(requestId, runId)) return; setRaw('Loading event...'); - const detail = await api(`/api/traces/${encodeURIComponent(runId)}/events/${seq}`); + const detail = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}/events/${seq}`), {signal:traceController?.signal ?? null}); if (!isCurrentRequest(requestId, runId) || selectedSeq !== seq) return; setRaw(detail?.raw || (detail ? '(empty trace event)' : 'Trace event could not be loaded.')); } -export async function openTraceDrawer(runId: string, seq?: number): Promise { +export async function openTraceDrawer(runId: string, seq?: number, sessionId: string | null = null): Promise { const overlay = ensureDrawer(); const requestId = ++openRequestId; - const startOffset = requestedOffset(seq); + // Sequence is a sparse trace identity, not an ordinal in the retained row list. + const startOffset = 0; + traceController?.abort(); traceController = new AbortController(); + traceSession = sessionId; + returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; currentRunId = runId; loadedCount = startOffset; totalCount = 0; @@ -157,7 +171,8 @@ export async function openTraceDrawer(runId: string, seq?: number): Promise(`/api/traces/${encodeURIComponent(runId)}`); + overlay.querySelector('.trace-drawer-close')?.focus({preventScroll:true}); + const summary = await api(tracePath(`/api/traces/${encodeURIComponent(runId)}`), {signal:traceController.signal}); if (!isCurrentRequest(requestId, runId)) return; if (!summary) { setRaw('Trace is unavailable or internal-only.'); return; } renderSummary(summary); diff --git a/tests/unit/trace-drawer-runtime.test.ts b/tests/unit/trace-drawer-runtime.test.ts index cf68e96a3..0df020abb 100644 --- a/tests/unit/trace-drawer-runtime.test.ts +++ b/tests/unit/trace-drawer-runtime.test.ts @@ -53,7 +53,7 @@ test.afterEach(() => { resetWebUiDom(); }); -test('openTraceDrawer loads the page containing the clicked seq and selects it', async () => { +test('openTraceDrawer selects sparse seq directly without treating it as a row offset', async () => { setupWebUiDom(); installScrollIntoView(); const calls: string[] = []; @@ -74,7 +74,7 @@ test('openTraceDrawer loads the page containing the clicked seq and selects it', startedAt: 1, }); } - if (url === '/api/traces/tr_run/events?offset=80&limit=80') { + if (url === '/api/traces/tr_run/events?offset=0&limit=80') { return apiData({ total: 145, events: [ @@ -100,14 +100,108 @@ test('openTraceDrawer loads the page containing the clicked seq and selects it', await openTraceDrawer('tr_run', 143); await nextTick(); - assert.ok(calls.includes('/api/traces/tr_run/events?offset=80&limit=80')); - assert.equal(calls.includes('/api/traces/tr_run/events?offset=0&limit=80'), false); + assert.ok(calls.includes('/api/traces/tr_run/events?offset=0&limit=80')); + assert.equal(calls.includes('/api/traces/tr_run/events?offset=80&limit=80'), false); assert.equal(document.getElementById('traceEventRaw')?.textContent, 'RAW-143'); const selected = document.querySelector('.trace-event-row[aria-current="true"]'); assert.equal(selected?.dataset['seq'], '143'); assert.equal(selected?.dataset['runId'], 'tr_run'); }); +test('summary, page and detail retain the session captured before an awaited open', async () => { + setupWebUiDom(); installScrollIntoView(); + let sessionId = 'chat-old'; + const summary = deferredResponse(); + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/traces/tr_owned?session=chat-old') return summary.promise; + if (url === '/api/traces/tr_owned/events?offset=0&limit=80&session=chat-old') { + return apiData({total:1,events:[{seq:900,source:'runtime',eventType:'tool',preview:'safe'}]}); + } + if (url === '/api/traces/tr_owned/events/900?session=chat-old') return apiData({runId:'tr_owned',seq:900,raw:'OWNED'}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {openTraceDrawer} = await import('../../public/js/features/trace-drawer.ts'); + const pending = openTraceDrawer('tr_owned',900,sessionId); + sessionId = 'chat-new'; + summary.resolve(apiData({id:'tr_owned',cli:'pi',model:'test',agentLabel:'agent',eventCount:1,byteCount:1,startedAt:1,rawRetentionStatus:'available',status:'done'})); + await pending; await nextTick(); + assert.equal(document.getElementById('traceEventRaw')?.textContent,'OWNED'); + assert.equal(calls.filter(url=>url.startsWith('/api/traces/')).length,3); + assert.ok(calls.filter(url=>url.startsWith('/api/traces/')).every(url=>url.includes('session=chat-old'))); + assert.equal(sessionId,'chat-new'); +}); + +test('closing the drawer cancels its read and ignores a late result', async () => { + setupWebUiDom(); installScrollIntoView(); + const response = deferredResponse(); + let signal: AbortSignal | null | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/auth/token') return jsonResponse({token:''}); + signal = init?.signal; + return response.promise; + }) as typeof fetch; + const drawer = await import('../../public/js/features/trace-drawer.ts'); + const pending = drawer.openTraceDrawer('tr_closed',undefined,'chat'); + await nextTick(); + document.querySelector('.trace-drawer-close')!.click(); + assert.equal(signal?.aborted,true); + response.resolve(apiData({id:'tr_closed',cli:'pi',eventCount:1,status:'done'})); + await pending; + assert.equal(document.querySelector('#traceDrawerOverlay.open'),null); + assert.equal(document.querySelector('.trace-event-row'),null); +}); + +test('the existing process Trace control resolves a server-owned default session before opening', async () => { + setupWebUiDom(); installScrollIntoView(); + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/orchestrate/snapshot') return apiData({activityIdentity:{sessionId:'server-active',scope:'remote:scope'}}); + if (url === '/api/traces/tr_clicked?session=server-active') return apiData({id:'tr_clicked',cli:'pi',model:'test',agentLabel:'agent',eventCount:0,byteCount:0,startedAt:1,rawRetentionStatus:'available',status:'done'}); + if (url.includes('/events?offset=0&limit=80&session=server-active')) return apiData({total:0,events:[]}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {bindProcessBlockInteractions} = await import('../../public/js/features/process-block.ts'); + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); bindProcessBlockInteractions(root); + root.querySelector('button')!.click(); + // Lazy import then JSON body consumption complete before this event-loop boundary. + await nextTick(); await nextTick(); + assert.ok(calls.includes('/api/traces/tr_clicked?session=server-active')); + assert.ok(calls.includes('/api/traces/tr_clicked/events?offset=0&limit=80&session=server-active')); +}); + +test('a late snapshot from earlier Trace click cannot reopen it over a newer click', async () => { + setupWebUiDom(); installScrollIntoView(); + const snapshots = [deferredResponse(),deferredResponse()]; + const calls: string[] = []; let index = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); calls.push(url); + if (url === '/api/auth/token') return jsonResponse({token:''}); + if (url === '/api/orchestrate/snapshot') return snapshots[index++]!.promise; + if (url === '/api/traces/tr_second?session=chat') return apiData({id:'tr_second',cli:'pi',model:'test',agentLabel:'agent',eventCount:0,byteCount:0,startedAt:1,rawRetentionStatus:'available',status:'done'}); + if (url.includes('/tr_second/events?')) return apiData({total:0,events:[]}); + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + const {bindProcessBlockInteractions} = await import('../../public/js/features/process-block.ts'); + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); bindProcessBlockInteractions(root); + const buttons = root.querySelectorAll('button'); + buttons[0]!.click(); buttons[1]!.click(); await nextTick(); + snapshots[1]!.resolve(apiData({activityIdentity:{sessionId:'chat',scope:'default'}})); + await nextTick(); await nextTick(); + snapshots[0]!.resolve(apiData({activityIdentity:{sessionId:'chat',scope:'default'}})); + await nextTick(); await nextTick(); + assert.match(document.getElementById('traceDrawerMeta')!.textContent!,/tr_second/); + assert.equal(calls.some(url=>url.includes('/api/traces/tr_first')),false); +}); + test('stale trace open responses cannot overwrite the newer clicked trace', async () => { setupWebUiDom(); installScrollIntoView(); From 64ffe521915f4b9eebd1d095bb5cf684e2f182e7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:14:57 +0900 Subject: [PATCH 12/33] docs: reconcile current parent protocol evidence --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index 7dcda5181..fc90553ff 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit 7dcda5181d293a92a019743d9ac4d5821978af27 +Subproject commit fc90553ff811e99c8b2a9acf29d04616baa414a4 From 560f0e1f9e192d430e5487cbc36aadf836fb2ad2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:41:22 +0900 Subject: [PATCH 13/33] feat: observe print Activity with captured presentation identity --- src/agent/events/helpers.ts | 9 ++ src/agent/runtime/print-activity.ts | 24 ++++ src/agent/runtime/print-projection.ts | 47 ++++++++ src/types/agent.ts | 5 + tests/unit/print-activity-projection.test.ts | 109 +++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 src/agent/runtime/print-activity.ts create mode 100644 src/agent/runtime/print-projection.ts create mode 100644 tests/unit/print-activity-projection.test.ts diff --git a/src/agent/events/helpers.ts b/src/agent/events/helpers.ts index 1d8a6cd10..b86725c76 100644 --- a/src/agent/events/helpers.ts +++ b/src/agent/events/helpers.ts @@ -50,10 +50,19 @@ export function emitAgentTool( tool: object, empTag: Record, ): void { + // Legacy parser entries are already accepted ToolEntry projections. Keep this + // observation separate from their unchanged messaging/progress payload. + const entry = tool as Partial; + if (typeof entry.icon === 'string' && typeof entry.label === 'string' && typeof entry.toolType === 'string' + && entry.toolType !== 'thinking' && entry.icon !== '💭' && entry.icon !== '💬') { + ctx.printActivity?.tool({ ...entry, icon: entry.icon, label: entry.label, toolType: entry.toolType }); + } const payload = { agentId: agentLabel, ...tool, ...empTag, + ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), + ...(ctx.activityIdentity ?? {}), startedAt: ctx.runStartedAt, // Who this event belongs to. A subscriber that cannot answer that question // has to take every agent_tool on the bus, which is how one channel's diff --git a/src/agent/runtime/print-activity.ts b/src/agent/runtime/print-activity.ts new file mode 100644 index 000000000..2d545b05b --- /dev/null +++ b/src/agent/runtime/print-activity.ts @@ -0,0 +1,24 @@ +import type { RuntimeEventContext } from './events.js'; +import { markActivityFailure } from '../../trace/activity-journal.js'; +import { RuntimeProjection } from './projection.js'; +import { createPrintActivityProjection, type PrintActivityProjection } from './print-projection.js'; + +/** Reuses canonical redaction, preview bounds and the existing per-run gap latch. */ +export function createPrintActivity(context: RuntimeEventContext, provider: string): PrintActivityProjection { + const projection: RuntimeProjection = new RuntimeProjection(context, undefined, reason => { + if (reason === 'capacity' || reason === 'truncated') { + markActivityFailure(context, 'run_limit'); + projection.report('persistence'); + } + }); + projection.start(provider); + return createPrintActivityProjection(body => { + switch (body.kind) { + case 'message': projection.text('message', body.itemId, body.text, body.operation, body.phase); break; + case 'reasoning': projection.text('reasoning', body.itemId, body.text, body.operation); break; + case 'tool': projection.tool(body.itemId, { name: body.name, status: body.status, + ...(body.detail === undefined ? {} : { detail: body.detail }) }); break; + case 'turn-end': projection.close(body); break; + } + }); +} diff --git a/src/agent/runtime/print-projection.ts b/src/agent/runtime/print-projection.ts new file mode 100644 index 000000000..b8e9496d2 --- /dev/null +++ b/src/agent/runtime/print-projection.ts @@ -0,0 +1,47 @@ +import type { RuntimeEventBody, RuntimePhase } from '../../shared/runtime-contract.js'; + +export type PrintToolInput = { icon: string; label: string; toolType: string; detail?: string; + stepRef?: string; traceSeq?: number; status?: string }; +export interface PrintActivityProjection { + nextMessage(): void; + message(text: string, operation: 'append' | 'replace', phase?: RuntimePhase): void; + reasoning(text: string, operation: 'append' | 'replace'): void; + tool(entry: PrintToolInput): void; + finish(end: Extract): void; +} + +/** Observes accepted print content, never selects an answer or retains output buffers. */ +export function createPrintActivityProjection(emit: (body: RuntimeEventBody) => void): PrintActivityProjection { + let message = 1, thought = 0, anonymous = 0; + let closed = false, failed = false, currentThought: string | null = null; + const send = (body: RuntimeEventBody): void => { + if (closed || failed) return; + try { emit(body); } + catch { failed = true; console.warn('[activity:print] observer_failed'); } + }; + return { + nextMessage() { if (!closed) { message++; currentThought = null; } }, + message(text, operation, phase = 'unknown') { + send({ kind: 'message', itemId: `print:message:${message}`, phase, text, operation }); + }, + reasoning(text, operation) { + if (operation === 'replace' || currentThought === null) currentThought = `print:reasoning:${++thought}`; + send({ kind: 'reasoning', itemId: currentThought, text, operation }); + }, + tool(entry) { + const itemId = entry.stepRef ? `print:ref:${entry.stepRef}` + : entry.traceSeq ? `print:trace:${entry.traceSeq}` : `print:anonymous:${++anonymous}`; + if (entry.icon === '💬') { + send({ kind: 'message', itemId, phase: 'unknown', text: entry.detail ?? entry.label, operation: 'replace' }); + } else if (entry.toolType === 'thinking' || entry.icon === '💭') { + send({ kind: 'reasoning', itemId, text: entry.detail ?? entry.label, operation: 'replace' }); + } else { + const status = entry.status === 'error' ? 'error' : entry.status === 'done' ? 'done' + : entry.status === 'stopped' ? 'stopped' : 'running'; + send({ kind: 'tool', itemId, name: entry.label, status, + ...(entry.detail === undefined ? {} : { detail: entry.detail }) }); + } + }, + finish(end) { if (!closed) { send(end); closed = true; } }, + }; +} diff --git a/src/types/agent.ts b/src/types/agent.ts index aedaec29c..5cd9f380b 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -4,6 +4,8 @@ import type { WatchdogHandle } from '../agent/watchdog.js'; import type { TracePointer } from '../trace/types.js'; import type { RuntimeTurnOutcome } from '../shared/runtime-contract.js'; +import type { ActivityIdentity } from '../shared/presentation.js'; +import type { PrintActivityProjection } from '../agent/runtime/print-projection.js'; export interface ToolEntry { icon: string; @@ -46,6 +48,9 @@ export type AgyLastActivitySource = 'stdout' | 'stderr' | 'transcript' | 'none'; /** Context object created per spawnAgent() invocation. */ export interface SpawnContext { + /** Captured jaw owner for presentation; sessionId below remains the provider ID. */ + activityIdentity?: ActivityIdentity; + printActivity?: PrintActivityProjection; /** Explicit native result; never inferred from compatibility text or Activity. */ runtimeOutcome?: RuntimeTurnOutcome; fullText: string; diff --git a/tests/unit/print-activity-projection.test.ts b/tests/unit/print-activity-projection.test.ts new file mode 100644 index 000000000..a31ba80b9 --- /dev/null +++ b/tests/unit/print-activity-projection.test.ts @@ -0,0 +1,109 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPrintActivityProjection } from '../../src/agent/runtime/print-projection.js'; +import { createPrintActivity } from '../../src/agent/runtime/print-activity.js'; +import { startTraceRun, finalizeTraceRun } from '../../src/trace/store.js'; +import { readActivityPage } from '../../src/trace/activity-journal.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { settings } from '../../src/core/config.js'; +import { emitAgentTool } from '../../src/agent/events/helpers.js'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.js'; +import type { RuntimeEventBody } from '../../src/shared/runtime-contract.js'; +import type { SpawnContext } from '../../src/types/agent.js'; + +test('pure observer keeps message boundaries, operations, unknown phase and stable tool references', () => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.message('A', 'append'); observer.message('B', 'append'); observer.nextMessage(); + observer.message('commentary', 'replace', 'commentary'); + observer.reasoning('think', 'append'); observer.reasoning(' more', 'append'); + observer.tool({ icon: 'x', label: 'cmd', toolType: 'tool', stepRef: 'stable', status: 'running' }); + observer.tool({ icon: 'x', label: 'cmd', toolType: 'tool', stepRef: 'stable', status: 'done', detail: '' }); + assert.equal(seen[0]?.kind, 'message'); + assert.equal(seen[0]?.kind === 'message' && seen[0].phase, 'unknown'); + assert.equal(seen[0]?.kind === 'message' && seen[0].itemId, seen[1]?.kind === 'message' && seen[1].itemId); + assert.notEqual(seen[0]?.kind === 'message' && seen[0].itemId, seen[2]?.kind === 'message' && seen[2].itemId); + assert.equal(seen[3]?.kind === 'reasoning' && seen[3].itemId, seen[4]?.kind === 'reasoning' && seen[4].itemId); + assert.equal(seen[5]?.kind === 'tool' && seen[5].itemId, seen[6]?.kind === 'tool' && seen[6].itemId); + assert.equal(seen[6]?.kind === 'tool' && seen[6].detail, ''); +}); + +test('anonymous tools stay distinct; narration and thinking never become real tools', () => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.tool({ icon: '💬', label: 'narration', toolType: 'thinking' }); + observer.tool({ icon: '💭', label: 'thought', toolType: 'thinking' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool' }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool', traceSeq: 2 }); + observer.tool({ icon: 'x', label: 'same', toolType: 'tool', stepRef: 'trace:2' }); + assert.deepEqual(seen.map(e => e.kind), ['message', 'reasoning', 'tool', 'tool', 'tool', 'tool']); + assert.equal(new Set(seen.filter(e => e.kind === 'tool').map(e => e.itemId)).size, 4); +}); + +test('pure observer finishes exactly once and contains a throwing sink', t => { + const seen: RuntimeEventBody[] = []; + const observer = createPrintActivityProjection(body => seen.push(body)); + observer.finish({ kind: 'turn-end', status: 'done', finalText: '' }); + observer.finish({ kind: 'turn-end', status: 'done', finalText: 'late' }); + observer.message('late', 'append'); + assert.deepEqual(seen, [{ kind: 'turn-end', status: 'done', finalText: '' }]); + t.mock.method(console, 'warn', () => {}); + let calls = 0; + const failed = createPrintActivityProjection(() => { calls++; throw new Error('fixture'); }); + assert.doesNotThrow(() => { failed.message('A', 'append'); failed.message('B', 'append'); failed.finish({ kind: 'turn-end', status: 'error', finalText: null }); }); + assert.equal(calls, 1); +}); + +test('factory uses actual journal and preserves selected null/empty/whitespace application finals', () => { + for (const finalText of [null, '', ' \n', 'selected final']) { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + observer.message('unfinished narration', 'append'); + observer.finish({ kind: 'turn-end', status: 'done', finalText }); finalizeTraceRun(runId, 'done'); + const p = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + const end = p.events.at(-1); + assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, finalText); assert.equal(p.incomplete, false); + assert.equal(p.events.filter(e => e.kind === 'turn-start').length, 1); + } +}); + +test('preview capacity fails visibly through one gap, with no fabricated terminal', () => { + const seen: BusEvent[] = []; const unsubscribe = subscribe(e => seen.push(e)); + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + try { + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + observer.message('x'.repeat(4000), 'append'); observer.message('late', 'append'); + observer.finish({ kind: 'turn-end', status: 'done', finalText: 'still selected by lifecycle' }); + const p = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.incomplete, true); assert.equal(p.loss, 'run_limit'); + assert.equal(p.events.some(e => e.kind === 'turn-end'), false); + assert.equal(seen.filter(e => e.event === 'agent_runtime_gap').length, 1); + } finally { unsubscribe(); } +}); + +function context(): SpawnContext { + return { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), hasClaudeStreamEvents: false, + sessionId: 'native-private', cost: null, turns: null, duration: null, tokens: null, stderrBuf: '', + traceRunId: 'tr_captured1234567890', activityIdentity: { sessionId: 'jaw-captured', scope: 'captured-scope' } }; +} + +test('tool wire identity is captured after payload overrides even with multi-session disabled', () => { + const before = settings.multiSession.enabled; settings.multiSession.enabled = false; + const seen: BusEvent[] = []; const legacy: Record[] = []; + const unsubscribe = subscribe(e => seen.push(e)); + const listener = (_type: string, data: Record) => legacy.push(data); + addBroadcastListener(listener); + try { + const ctx = context(); + emitAgentTool(ctx, 'fixture', { icon: 'x', label: 'cmd', toolType: 'tool', traceRunId: 'spoof', sessionId: 'spoof', scope: 'spoof' }, + { sessionId: 'emp-spoof', scope: 'emp-spoof', traceRunId: 'emp-spoof' }); + assert.equal(seen[0]?.data['sessionId'], 'jaw-captured'); assert.equal(seen[0]?.data['scope'], 'captured-scope'); + assert.equal(seen[0]?.data['traceRunId'], ctx.traceRunId); + assert.ok(!JSON.stringify(seen).includes('native-private')); + ctx.traceAudience = 'internal'; emitAgentTool(ctx, undefined, { icon: 'x', label: 'cmd', toolType: 'tool' }, {}); + assert.equal(seen.length, 1, 'internal audience has no public SSE'); + assert.equal(legacy.length, 2, 'legacy internal listener contract remains'); + } finally { unsubscribe(); removeBroadcastListener(listener); settings.multiSession.enabled = before; } +}); From ebb5419e9554d90adb65c35091294aa101bc1993 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 03:44:09 +0900 Subject: [PATCH 14/33] feat: expose existing trace identity allocator --- src/trace/store.ts | 2 +- tests/unit/trace-identity.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/unit/trace-identity.test.ts diff --git a/src/trace/store.ts b/src/trace/store.ts index 14bf95e82..81e2842ab 100644 --- a/src/trace/store.ts +++ b/src/trace/store.ts @@ -82,7 +82,7 @@ const interruptStaleStmt = db.prepare(` const liveRunIdsStmt = db.prepare('SELECT id FROM trace_runs'); const seqCache = new Map(); -function createTraceId(): string { return `tr_${crypto.randomUUID().replace(/-/g, '')}`; } +export function createTraceId(): string { return `tr_${crypto.randomUUID().replace(/-/g, '')}`; } function ensureTraceDir(runId: string): string { const dir = join(TRACE_DIR, runId); fs.mkdirSync(dir, { recursive: true }); diff --git a/tests/unit/trace-identity.test.ts b/tests/unit/trace-identity.test.ts new file mode 100644 index 000000000..75fc51c57 --- /dev/null +++ b/tests/unit/trace-identity.test.ts @@ -0,0 +1,14 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { db } from '../../src/core/db.js'; +import { createTraceId, startTraceRun } from '../../src/trace/store.js'; + +test('exported trace identity allocator keeps the existing format and needs no available database', () => { + const admitted = startTraceRun({ cli: 'fixture' }); + db.close(); + const fallback = createTraceId(); + assert.match(admitted, /^tr_[a-f0-9]{32}$/); + assert.match(fallback, /^tr_[a-f0-9]{32}$/); + assert.notEqual(fallback, admitted); +}); From d2b4d4ac530012199f73594201aae5202a9d64d6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:00:55 +0900 Subject: [PATCH 15/33] test: verify display-only settings through runtime behavior --- structure/server_api.md | 2 +- tests/unit/cli-switch-refresh.test.ts | 49 +++++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/structure/server_api.md b/structure/server_api.md index d462e69ca..22001ccf6 100644 --- a/structure/server_api.md +++ b/structure/server_api.md @@ -189,7 +189,7 @@ Cursor/Grok activation and Activity controls are separate from this API foundati | Dashboard Schedule | `GET /api/dashboard/schedule/work` `POST /api/dashboard/schedule/work` `PATCH /api/dashboard/schedule/work/:id` `DELETE /api/dashboard/schedule/work/:id` `POST /api/dashboard/schedule/work/:id/dispatch` | | i18n | `GET /api/i18n/languages` `GET /api/i18n/:lang` | -> 실제 코드(`server.ts` + `src/routes/*.ts` + mounted runtime/security/Jaw CEO/dashboard sub-router)에서 추출한 총 256개 route handler 기준이다. 이 중 API 엔드포인트는 255개이고, 나머지 1개는 `/` 엔트리이다. Browser API 43개는 `src/routes/browser.ts`에서 등록된다. Jaw CEO 20개는 `src/routes/jaw-ceo.ts`에서 sub-router로 등록된다. +> 실제 코드(`server.ts` + `src/routes/*.ts` + mounted runtime/security/Jaw CEO/dashboard sub-router)에서 추출한 총 260개 route handler 기준이다. 이 중 API 엔드포인트는 259개이고, 나머지 1개는 `/` 엔트리이다. Browser API 43개는 `src/routes/browser.ts`에서 등록된다. Jaw CEO 20개는 `src/routes/jaw-ceo.ts`에서 sub-router로 등록된다. `PUT /api/heartbeat`의 job은 `mentionWatch: { channel: "slack", userId: "U...", channelIds: ["C..."], maxHits?, since? }`를 선택적으로 받는다. `channelIds`는 비어 있지 않아야 하고 저장 시 `slack.channelIds` allowlist의 부분집합이어야 하며, 실행 tick 직전 현재 allowlist와 다시 교집합한다. job id가 같은 기존 값에 대해 필드가 없으면 상속하고, `null`이면 삭제하며, 잘못된 값은 `400 invalid heartbeat mention watch`다. 파일 로드 정규화에서 잘못된 `mentionWatch`는 해당 job을 `enabled: false`로 내린다. 기본 운영값은 비활성이고, 설정된 watch는 별도 daemon이 아니라 기존 `runHeartbeatJob`에서 실행된다. diff --git a/tests/unit/cli-switch-refresh.test.ts b/tests/unit/cli-switch-refresh.test.ts index 00634c16c..ee3d7e3e1 100644 --- a/tests/unit/cli-switch-refresh.test.ts +++ b/tests/unit/cli-switch-refresh.test.ts @@ -1,3 +1,4 @@ +import '../setup/isolated-home.ts'; import { readSource } from './source-normalize.js'; // CLI Switch Session Refresh — Issue #126 // Mostly source-pattern assertions following existing test style (phase31-runtime, employee-session-reuse). @@ -65,8 +66,27 @@ test('CSR-005b: ai-e provider change triggers clean session refresh', () => { assert.match(runtimeSrc, /toProvider:\s*toCli\s*===\s*'ai-e'\s*\?\s*nextAiEProvider\s*:\s*undefined/); }); -test('CSR-006: cli unchanged branch keeps original syncMainSessionToSettings(prevCli)', () => { - assert.match(runtimeSrc, /\}\s*else\s*\{\s*syncMainSessionToSettings\(prevCli\)/); +test('CSR-006: unchanged CLI synchronizes execution settings while display-only writes preserve the session', async t => { + const config = await import('../../src/core/config.ts'); + const { applyRuntimeSettingsPatch } = await import('../../src/core/runtime-settings.ts'); + const { updateSession, getSession } = await import('../../src/core/db.ts'); + const original = config.snapshotSettingsState(); + t.after(() => config.commitCandidate(original)); + const baseline = structuredClone(config.settings); + baseline.cli = 'claude'; baseline.activeOverrides = {}; + baseline.perCli.claude = { model: 'configured-model', effort: 'high' }; + config.commitCandidate({ value: baseline, shape: original.shape }); + updateSession.run('claude', 'captured-session', 'old-model', 'auto', baseline.workingDir, 'low'); + await applyRuntimeSettingsPatch({ perCli: { claude: { model: 'new-model' } } }, { + writeSettings: () => {}, restartMessaging: async () => {}, + }); + assert.equal(getSession()?.model, 'new-model'); + assert.equal(getSession()?.session_id, 'captured-session'); + const selected = getSession(); + await applyRuntimeSettingsPatch({ presentation: { mode: 'legacy' } }, { + writeSettings: () => {}, restartMessaging: async () => {}, + }); + assert.deepEqual(getSession(), selected); }); test('CSR-007: codex-spark bucket targeted via toModel (not null)', () => { @@ -203,14 +223,23 @@ test('CSR-013: no-content switch preserves existing pending bootstrap', () => { assert.doesNotMatch(body, /setPendingBootstrapPromptStrict\(null\)/); }); -test('CSR-012: cli-changed branch does NOT call syncMainSessionToSettings', () => { - // Capture the if(cliChanged){...} block and verify no syncMainSessionToSettings inside - const ifBlock = runtimeSrc.match(/if\s*\(\s*cliChanged\s*\|\|\s*aiEProviderChanged\s*\)\s*\{([\s\S]*?)\}\s*else\s*\{/); - assert.ok(ifBlock, 'cli/provider changed branch must exist'); - assert.ok( - !/syncMainSessionToSettings/.test(ifBlock![1]), - 'cli-changed branch must delegate main-session clearing to cliSwitchRefresh', - ); +test('CSR-012: CLI change leaves session ownership to the explicit refresh boundary', async t => { + const config = await import('../../src/core/config.ts'); + const { applyRuntimeSettingsPatch } = await import('../../src/core/runtime-settings.ts'); + const { updateSession, getSession } = await import('../../src/core/db.ts'); + const original = config.snapshotSettingsState(); + t.after(() => config.commitCandidate(original)); + const baseline = structuredClone(config.settings); baseline.cli = 'claude'; + config.commitCandidate({ value: baseline, shape: original.shape }); + updateSession.run('claude', 'must-survive-harvest', 'original-model', 'auto', baseline.workingDir, 'high'); + const before = getSession(); + let refreshes = 0; + await applyRuntimeSettingsPatch({ cli: 'codex-app' }, { + writeSettings: () => {}, restartMessaging: async () => {}, + cliSwitchRefresh: async () => { refreshes++; assert.deepEqual(getSession(), before); }, + }); + assert.equal(refreshes, 1); + assert.deepEqual(getSession(), before, 'no extra singleton write outside the refresh owner'); }); // ─── Behavioral test: real DB round-trip for the strict setter ─── From d088828a799457afa5bc6bda1267825c027736c4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:03:02 +0900 Subject: [PATCH 16/33] feat: retain accepted print activity and durable tool updates --- src/agent/events/claude.ts | 14 +- src/agent/events/codex.ts | 3 + src/agent/events/cursor.ts | 67 ++- src/agent/events/grok.ts | 36 +- src/agent/events/index.ts | 12 +- src/agent/events/opencode.ts | 64 ++- src/trace/store.ts | 26 +- tests/unit/print-provider-observation.test.ts | 423 ++++++++++++++++++ 8 files changed, 606 insertions(+), 39 deletions(-) create mode 100644 tests/unit/print-provider-observation.test.ts diff --git a/src/agent/events/claude.ts b/src/agent/events/claude.ts index 134d54ccd..e46041349 100644 --- a/src/agent/events/claude.ts +++ b/src/agent/events/claude.ts @@ -2,7 +2,7 @@ import { appendBoundedFullText } from './fulltext-bound.js'; import { fieldString } from '../../types/cli-events.js'; -import { updateTraceToolRow, getTraceEvent } from '../../trace/store.js'; +import { updateTraceToolRow, getTraceToolEntry } from '../../trace/store.js'; import type { CliEventRecord } from './types.js'; import type { SpawnContext, ToolEntry } from './types.js'; import { @@ -147,6 +147,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st const previous = ctx.claudeILastAssistantText || ''; ctx.claudeILastAssistantText = text; if (text === previous || previous.startsWith(text)) return ''; + ctx.printActivity?.message(text, 'replace', 'unknown'); if (text.startsWith(previous)) { const delta = text.slice(previous.length); { @@ -176,6 +177,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st if (messageId) ctx.claudeILastAssistantId = messageId; else delete ctx.claudeILastAssistantId; ctx.claudeILastAssistantText = text; + ctx.printActivity?.message(text, 'replace', 'unknown'); return appendAssistantTextSegment(ctx, text); } @@ -192,6 +194,7 @@ function appendClaudeISnapshotText(ctx: SpawnContext, event: CliEventRecord): st * (spawn.ts gates that to kiro-plain/agy/pi), and this mirrors the same defensive * check the reconcile path already carries. */ export function resetClaudeDurableMessage(ctx: SpawnContext): void { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; if (ctx.liveOutputText !== undefined) ctx.liveOutputText = ''; ctx.outputTextStarted = false; @@ -264,6 +267,7 @@ export function handleClaudeEvent( const hasCanonicalText = evt.message.content.some( (block) => block.type === 'text' && block.text, ); + if (hasCanonicalText) ctx.printActivity?.message(extractAssistantText(evt), 'replace', 'unknown'); if (ctx.claudeStreamedTextStart !== undefined && hasCanonicalText) { const useLive = ctx.liveOutputText !== undefined; const target = useLive ? ctx.liveOutputText! : ctx.fullText; @@ -297,6 +301,7 @@ export function handleClaudeEvent( if (fallbackId) ctx.claudeILastAssistantId = fallbackId; for (const block of evt.message.content) { if (block.type === 'text') { + if (block.text) ctx.printActivity?.message(block.text, 'append', 'unknown'); const segment = appendAssistantTextSegment(ctx, block.text); ctx.pendingOutputChunk = (ctx.pendingOutputChunk || '') + segment; } @@ -339,11 +344,7 @@ export function handleClaudeEvent( // status (WP4, devlog 260703 doc 12 item 3). const pointer = ctx.toolTraceIndex?.get(`claude:tooluse:${block.tool_use_id}`); if (pointer) { - let base: Partial = {}; - const row = getTraceEvent(pointer.traceRunId, pointer.traceSeq); - if (row?.raw) { - try { base = JSON.parse(row.raw) as Partial; } catch { /* keep minimal base */ } - } + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); const resultText = extractText(block.content); const merged: ToolEntry = { toolType: 'tool', @@ -360,6 +361,7 @@ export function handleClaudeEvent( : resultText; } updateTraceToolRow(merged); + ctx.printActivity?.tool(merged); } } } diff --git a/src/agent/events/codex.ts b/src/agent/events/codex.ts index 3edaac540..d934fb62f 100644 --- a/src/agent/events/codex.ts +++ b/src/agent/events/codex.ts @@ -25,6 +25,9 @@ export function handleCodexEvent( if (evt.item?.type === 'agent_message') { const text = String(evt.item.text || ''); const channel = evt.item?.['channel'] || (evt.item?.['annotations'] as Record | undefined)?.['channel']; + ctx.printActivity?.nextMessage(); + ctx.printActivity?.message(text, 'replace', + channel === 'final' ? 'final' : channel === 'commentary' ? 'commentary' : 'unknown'); // Commentary-channel messages are transient progress updates — do NOT // persist them in fullText so they stay out of agent_done and therefore // out of Slack/Telegram/Discord delivery. diff --git a/src/agent/events/cursor.ts b/src/agent/events/cursor.ts index 5eaefc99c..1e3adefc5 100644 --- a/src/agent/events/cursor.ts +++ b/src/agent/events/cursor.ts @@ -1,6 +1,7 @@ // Cursor CLI stream-json adapter. import { stripUndefined } from '../../core/strip-undefined.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import { asCliEventRecord, fieldNumber, fieldString } from '../../types/cli-events.js'; import type { CliEventRecord, SpawnContext, ToolEntry } from './types.js'; import { @@ -68,6 +69,7 @@ function appendCursorAssistantText(ctx: SpawnContext, event: CliEventRecord): st // observed failures are all the first shape. Cumulative snapshot growth (the // prefix case) is unaffected. if (cursorStartsNewAssistantMessage(ctx, event, text, isDelta)) { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; ctx.outputTextStarted = false; // Drop the dedupe baseline too. It describes the message being replaced, and @@ -88,6 +90,7 @@ function appendCursorAssistantText(ctx: SpawnContext, event: CliEventRecord): st : (text.startsWith(previous) ? text.slice(previous.length) : text); if (!segmentText) return ''; + ctx.printActivity?.message(segmentText, 'append', 'unknown'); ctx.cursorAssistantText = isDelta ? `${previous}${text}` : text; return appendAssistantTextSegment(ctx, segmentText); } @@ -185,14 +188,50 @@ function emitCursorTool( tool: ToolEntry, ): void { const key = [tool.icon, tool.label, tool.stepRef || '', tool.status || ''].join(':'); - if (ctx.seenToolKeys?.has(key)) return; - ctx.seenToolKeys?.add(key); - const existingIdx = tool.stepRef && (tool.status === 'done' || tool.status === 'error') - ? ctx.toolLog.findIndex((entry) => entry.stepRef === tool.stepRef && entry.status === 'running') + const existingIdx = tool.stepRef + ? ctx.toolLog.findIndex((entry) => entry.stepRef === tool.stepRef) : -1; + let prior = ctx.toolLog[existingIdx]; + const pointer = tool.stepRef ? ctx.toolTraceIndex?.get(tool.stepRef) : undefined; + if (!prior && pointer) { + prior = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq) ?? undefined; + } + // Late start snapshots must not reopen a completed tool, even with changed detail. + if (['done', 'error', 'stopped'].includes(prior?.status || '') + && !['done', 'error', 'stopped'].includes(tool.status || '')) return; + if (ctx.seenToolKeys?.has(key) && (!prior || prior.detail === tool.detail)) return; + ctx.seenToolKeys?.add(key); + // Admission precedes all text/message-boundary effects as well as tool writes. + // LAST-WINS across tool boundaries: assistant text that arrived BEFORE + // a tool ran is planning narration ("경계를 먼저 확인한 뒤 ..."), not part + // of the final answer. Cursor stream-json has no channel tags, so the + // tool boundary is the only reliable seam — discard the durable + // accumulation when a NEW tool starts and keep only post-last-tool text. + // Only on 'running' (tool start): a late completion update arriving + // after the answer began must not wipe answer text. The delta/snapshot + // dedupe state (cursorAssistantText) is deliberately NOT reset, so a + // cumulative end-of-turn snapshot still dedupes to nothing instead of + // re-ingesting the discarded narration. Live UI keeps the narration via + // pendingOutputChunk/agent_output; only fullText (=agent_done → external + // channels) is affected. + if (tool.status === 'running' && (ctx.fullText || ctx.outputTextStarted)) { + ctx.printActivity?.nextMessage(); + ctx.fullText = ''; + ctx.outputTextStarted = false; + } + const traceRunId = pointer?.traceRunId ?? prior?.traceRunId; + const traceSeq = pointer?.traceSeq ?? prior?.traceSeq; + if (traceRunId && traceSeq) { + tool.traceRunId = traceRunId; + tool.traceSeq = traceSeq; + tool.detailAvailable = ctx.traceAudience !== 'internal'; + if (ctx.traceAudience === 'internal') tool.rawRetentionStatus = 'internal'; + else if (prior?.rawRetentionStatus !== undefined) tool.rawRetentionStatus = prior.rawRetentionStatus; + } if (existingIdx >= 0) ctx.toolLog[existingIdx] = tool; else ctx.toolLog.push(tool); syncLiveTools(ctx); + updateTraceToolRow(tool); emitAgentTool(ctx, agentLabel, tool, empTag); } @@ -221,22 +260,6 @@ export function handleCursorEvent( } if (event.type === 'tool_call') { - // LAST-WINS across tool boundaries: assistant text that arrived BEFORE - // a tool ran is planning narration ("경계를 먼저 확인한 뒤 ..."), not part - // of the final answer. Cursor stream-json has no channel tags, so the - // tool boundary is the only reliable seam — discard the durable - // accumulation when a NEW tool starts and keep only post-last-tool text. - // Only on 'running' (tool start): a late completion update arriving - // after the answer began must not wipe answer text. The delta/snapshot - // dedupe state (cursorAssistantText) is deliberately NOT reset, so a - // cumulative end-of-turn snapshot still dedupes to nothing instead of - // re-ingesting the discarded narration. Live UI keeps the narration via - // pendingOutputChunk/agent_output; only fullText (=agent_done → external - // channels) is affected. - if (cursorToolStatus(event) === 'running' && (ctx.fullText || ctx.outputTextStarted)) { - ctx.fullText = ''; - ctx.outputTextStarted = false; - } emitCursorTool(ctx, agentLabel, empTag, cursorToolLabel(event)); } @@ -258,7 +281,9 @@ export function handleCursorEvent( status: 'error', }); } else if (!ctx.fullText && typeof event["result"] === 'string') { - const segment = appendAssistantTextSegment(ctx, normalizeAssistantDisplayText(event["result"])); + const text = normalizeAssistantDisplayText(event["result"]); + ctx.printActivity?.message(text, 'append', 'unknown'); + const segment = appendAssistantTextSegment(ctx, text); ctx.pendingOutputChunk = (ctx.pendingOutputChunk || '') + segment; } } diff --git a/src/agent/events/grok.ts b/src/agent/events/grok.ts index 999b743d5..4e01a5f97 100644 --- a/src/agent/events/grok.ts +++ b/src/agent/events/grok.ts @@ -1,6 +1,7 @@ // Grok CLI event adapter import { appendBoundedFullText } from './fulltext-bound.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import { asCliEventRecord, fieldString, @@ -21,13 +22,24 @@ const GROK_THINKING_UPDATE_MIN_MS = 750; const GROK_THINKING_UPDATE_MIN_CHARS = 240; const GROK_THOUGHT_BUFFER_MAX = 102_400; +function findGrokTool(ctx: SpawnContext, ref: string): ToolEntry | undefined { + const existing = [...ctx.toolLog].reverse().find(t => t.stepRef === ref); + if (existing) return existing; + const pointer = ctx.toolTraceIndex?.get(ref); + if (!pointer) return undefined; + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); + return { icon: '🔧', label: 'tool', toolType: 'tool', ...base, stepRef: ref, + traceRunId: pointer.traceRunId, traceSeq: pointer.traceSeq, + detailAvailable: ctx.traceAudience !== 'internal', + ...(ctx.traceAudience === 'internal' ? { rawRetentionStatus: 'internal' as const } : {}), + }; +} + function findGrokThinkingTool(ctx: SpawnContext): ToolEntry | undefined { const currentRef = ctx.grokCurrentThoughtRef; if (currentRef) { - const current = [...ctx.toolLog].reverse().find( - (t: ToolEntry) => t.stepRef === currentRef && (!t.status || t.status === 'running') - ); - if (current) return current; + const current = findGrokTool(ctx, currentRef); + if (current && (!current.status || current.status === 'running')) return current; } return [...ctx.toolLog].reverse().find( (t: ToolEntry) => t.stepRef?.startsWith(GROK_THINKING_STEP_REF) && (!t.status || t.status === 'running') @@ -69,6 +81,7 @@ function ensureGrokThinkingProgress( if (existing) { existing.label = label; if (trimmed) existing.detail = trimmed; + updateTraceToolRow(existing); if (!shouldEmitGrokThinkingUpdate(ctx, trimmed)) return; syncLiveTools(ctx); emitAgentTool(ctx, agentLabel, existing, empTag); @@ -107,6 +120,7 @@ function finalizeGrokThinkingProgress( existing.detail = trimmed; } syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); delete ctx.grokCurrentThoughtRef; delete ctx.grokLastThoughtEmitAt; @@ -126,6 +140,7 @@ function finalizeAllGrokThinkingProgress( for (const thought of runningThoughts) { thought.status = 'done'; syncLiveTools(ctx); + updateTraceToolRow(thought); emitAgentTool(ctx, agentLabel, thought, empTag); } delete ctx.grokCurrentThoughtRef; @@ -238,14 +253,18 @@ function handleGrokToolEvent( ); if (startsTool && !endsTool) { - const existing = [...ctx.toolLog].reverse().find((t: ToolEntry) => t.stepRef === ref); + const existing = findGrokTool(ctx, ref); if (existing) { + // Includes rows recovered after RAM eviction; reject before mutation. + if (['done', 'error', 'stopped'].includes(existing.status || '')) return true; existing.icon = '🔧'; existing.label = buildPreview(name, 80) || existing.label || 'tool'; existing.toolType = 'tool'; existing.status = 'running'; if (detail) existing.detail = detail; + if (!ctx.toolLog.includes(existing)) ctx.toolLog.push(existing); syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); return true; } @@ -264,7 +283,7 @@ function handleGrokToolEvent( return true; } - const existing = [...ctx.toolLog].reverse().find((t: ToolEntry) => t.stepRef === ref); + const existing = findGrokTool(ctx, ref); const doneTool = existing || { icon: isError ? '❌' : '✅', label: buildPreview(name, 80) || 'tool', @@ -275,8 +294,9 @@ function handleGrokToolEvent( doneTool.label = buildPreview(name, 80) || doneTool.label || 'tool'; doneTool.status = isError ? 'error' : 'done'; if (detail) doneTool.detail = detail; - if (!existing) ctx.toolLog.push(doneTool); + if (!ctx.toolLog.includes(doneTool)) ctx.toolLog.push(doneTool); syncLiveTools(ctx); + updateTraceToolRow(doneTool); emitAgentTool(ctx, agentLabel, doneTool, empTag); pushTrace(ctx, `[${agentLabel}] grok tool ${isError ? 'error' : 'done'}: ${name}`); return true; @@ -318,6 +338,7 @@ export function handleGrokEvent( } if (evt.type === 'thought') { const text = String(evt.data || evt.text || ''); + if (text) ctx.printActivity?.reasoning(text, 'append'); const buf = (ctx.grokThoughtBuf || '') + text; ctx.grokThoughtBuf = buf.length > GROK_THOUGHT_BUFFER_MAX ? buf.slice(-GROK_THOUGHT_BUFFER_MAX) : buf; ensureGrokThinkingProgress(ctx, agentLabel, empTag, ctx.grokThoughtBuf); @@ -326,6 +347,7 @@ export function handleGrokEvent( if (evt.type === 'text') { const text = String(evt.data || evt.text || ''); if (text) { + ctx.printActivity?.message(text, 'append', 'unknown'); finalizeGrokThinkingProgress(ctx, agentLabel, empTag, ctx.grokThoughtBuf); ctx.grokThoughtBuf = ''; { diff --git a/src/agent/events/index.ts b/src/agent/events/index.ts index 281775052..e55a9afeb 100644 --- a/src/agent/events/index.ts +++ b/src/agent/events/index.ts @@ -27,7 +27,7 @@ import { updateTraceToolRow } from '../../trace/store.js'; import { handleCodexEvent } from './codex.js'; import { handleCursorEvent } from './cursor.js'; import { handleGrokEvent } from './grok.js'; -import { handleOpenCodeEvent } from './opencode.js'; +import { handleOpenCodeEvent, refreshOpenCodeTool } from './opencode.js'; import { extractToolLabels } from './tool-labels.js'; export function extractSessionId(cli: string, event: CliEventRecord): string | null { @@ -158,6 +158,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC // Buffer thinking deltas if (inner?.type === 'content_block_delta' && inner.delta?.type === 'thinking_delta') { + if (inner.delta.thinking) ctx.printActivity?.reasoning(inner.delta.thinking, 'append'); if (!ctx.claudeThinkingBuf) ctx.claudeThinkingBuf = ''; ctx.claudeThinkingBuf += inner.delta.thinking || ''; ctx.claudeThinkingHadDelta = true; @@ -182,6 +183,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC } const seg = appendAssistantRawText(ctx, deltaText); if (seg) { + ctx.printActivity?.message(deltaText, 'append', 'unknown'); // Arm the per-message guard only when real text flowed: an all-empty // text_delta run must NOT skip the complete-block fallback (else its // prose would be dropped). @@ -316,6 +318,7 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC const toolLabels = extractToolLabels(cli, event, ctx); for (const toolLabel of toolLabels) { + if (cli === 'opencode' && refreshOpenCodeTool(ctx, agentLabel, empTag, toolLabel)) continue; // Dedupe: same logic as ACP path — skip already-seen tool keys const key = [ toolLabel.icon, @@ -325,6 +328,13 @@ export function extractFromEvent(cli: string, event: CliEventRecord, ctx: SpawnC ].join(':'); if (ctx.seenToolKeys && ctx.seenToolKeys.has(key)) continue; if (ctx.seenToolKeys) ctx.seenToolKeys.add(key); + // Complete reasoning cards have no delta hook. Observe accepted plaintext + // once here; the shared tool emitter deliberately skips synthetic cards. + if (toolLabel.toolType === 'thinking' && toolLabel.detail + && ((cli === 'codex' && event.item?.type === 'reasoning') + || (isClaudeLikeCli(cli) && event.type === 'assistant' && !ctx.hasClaudeStreamEvents))) { + ctx.printActivity?.reasoning(toolLabel.detail, 'replace'); + } // Resolve running → done/error: replace existing running entry in toolLog if (toolLabel.stepRef && (toolLabel.status === 'done' || toolLabel.status === 'error')) { diff --git a/src/agent/events/opencode.ts b/src/agent/events/opencode.ts index d1ce196f9..75ef42590 100644 --- a/src/agent/events/opencode.ts +++ b/src/agent/events/opencode.ts @@ -1,6 +1,7 @@ // OpenCode CLI event adapter import { asCliEventRecord } from '../../types/cli-events.js'; +import { getTraceToolEntry, updateTraceToolRow } from '../../trace/store.js'; import type { CliEventRecord } from './types.js'; import type { SpawnContext, ToolEntry } from './types.js'; import { @@ -13,6 +14,51 @@ import { formatPostToolAssistantLead, } from './helpers.js'; +/** Refresh an existing OpenCode card, or consume a stale/duplicate update. + * Returns false only when the dispatcher must admit a new tool entry. */ +export function refreshOpenCodeTool( + ctx: SpawnContext, + agentLabel: string, + empTag: Record, + toolLabel: ToolEntry, +): boolean { + if (!toolLabel.stepRef) return false; + const index = ctx.toolLog.findIndex(t => t.stepRef === toolLabel.stepRef); + const pointer = ctx.toolTraceIndex?.get(toolLabel.stepRef); + let prior = ctx.toolLog[index]; + if (!prior && pointer) { + prior = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq) ?? undefined; + } + if (prior || pointer) { + // OpenCode may omit running status; do not let that stale card + // overwrite terminal detail/icon while inheriting its old status. + if (['done', 'error', 'stopped'].includes(prior?.status || '') + && !['done', 'error', 'stopped'].includes(toolLabel.status || '')) return true; + const key = [toolLabel.icon, toolLabel.label, toolLabel.stepRef, toolLabel.status || ''].join(':'); + const detail = toolLabel.detail ?? prior?.detail; + if (ctx.seenToolKeys?.has(key) && prior?.detail === detail) return true; + ctx.seenToolKeys?.add(key); + const refreshed: ToolEntry = { ...prior, ...toolLabel }; + if (detail !== undefined) refreshed.detail = detail; + if (pointer) { + refreshed.traceRunId = pointer.traceRunId; + refreshed.traceSeq = pointer.traceSeq; + refreshed.detailAvailable = ctx.traceAudience !== 'internal'; + if (ctx.traceAudience === 'internal') refreshed.rawRetentionStatus = 'internal'; + } + if (index >= 0) ctx.toolLog[index] = refreshed; + else ctx.toolLog.push(refreshed); + if ((refreshed.status === 'done' || refreshed.status === 'error') && ctx.opencodePendingToolRefs) { + ctx.opencodePendingToolRefs = ctx.opencodePendingToolRefs.filter(ref => ref !== refreshed.stepRef); + } + syncLiveTools(ctx); + updateTraceToolRow(refreshed); + emitAgentTool(ctx, agentLabel, refreshed, empTag); + return true; + } + return false; +} + function flushOpenCodeStepText( ctx: SpawnContext, agentLabel: string | undefined, @@ -67,13 +113,26 @@ function finalizeOpencodePendingTools( if (!pendingRefs.length) return; const failed = !!ctx.opencodeHadToolErrorInStep; for (const ref of pendingRefs) { - const existing = [...ctx.toolLog].reverse().find( + let existing = [...ctx.toolLog].reverse().find( (t: ToolEntry) => t.stepRef === ref && (!t.status || t.status === 'running') ); + if (!existing) { + const pointer = ctx.toolTraceIndex?.get(ref); + if (pointer) { + const base = getTraceToolEntry(pointer.traceRunId, pointer.traceSeq); + existing = { icon: '🔧', label: 'tool', toolType: 'tool', ...base, stepRef: ref, + traceRunId: pointer.traceRunId, traceSeq: pointer.traceSeq, + detailAvailable: ctx.traceAudience !== 'internal', + ...(ctx.traceAudience === 'internal' ? { rawRetentionStatus: 'internal' as const } : {}), + }; + } + } if (!existing) continue; + if (existing.status && existing.status !== 'running') continue; existing.status = failed ? 'error' : 'done'; existing.icon = failed ? '❌' : '✅'; syncLiveTools(ctx); + updateTraceToolRow(existing); emitAgentTool(ctx, agentLabel, existing, empTag); } } @@ -114,6 +173,7 @@ export function handleOpenCodeEvent( return; } if (evt.type === 'step_start') { + ctx.printActivity?.nextMessage(); const model = evt.part?.model || evt.model; if (model) ctx.model = model; // LAST-STEP-WINS (NARRATION-BOUNDARY-01): a NEW step means the text the @@ -139,6 +199,7 @@ export function handleOpenCodeEvent( if (evt.type === 'reasoning') { const text = String(evt.part?.text || evt.text || '').trim(); if (text) { + ctx.printActivity?.reasoning(text, 'replace'); const thinkingTool = { icon: '💭', label: buildPreview(text, 80) || 'thinking...', @@ -153,6 +214,7 @@ export function handleOpenCodeEvent( pushTrace(ctx, `[${agentLabel}] opencode reasoning (${text.length} chars)`); } } else if (evt.type === 'text' && evt.part?.text) { + ctx.printActivity?.message(String(evt.part.text), 'append', 'unknown'); if (ctx.opencodeSawToolInStep) { ctx.opencodePostToolText = (ctx.opencodePostToolText || '') + String(evt.part.text); } else { diff --git a/src/trace/store.ts b/src/trace/store.ts index 81e2842ab..d25197148 100644 --- a/src/trace/store.ts +++ b/src/trace/store.ts @@ -60,7 +60,7 @@ const maxSeqStmt = db.prepare('SELECT MAX(seq) AS seq FROM trace_events WHERE ru const listRunsForMessageStmt = db.prepare( 'SELECT id, audience, started_at FROM trace_runs WHERE message_id = ? ORDER BY started_at ASC, id ASC'); const listToolEventsForRunStmt = db.prepare(` - SELECT seq, event_type, raw_json, raw_path + SELECT seq, event_type, raw_json, raw_path, bytes, retention_status FROM trace_events WHERE run_id = ? AND source = 'tool' ORDER BY seq ASC LIMIT ? `); // WP4 (devlog 260703 doc 12): live-run hydration reads the NEWEST tool rows and the @@ -327,10 +327,18 @@ export function listToolEntriesForMessage( for (const run of runs) { if (wantAudience === 'public' && run.audience === 'internal') continue; const events = listToolEventsForRunStmt.all(run.id, perRunLimit) as - { seq: number; event_type: string; raw_json: string | null; raw_path: string | null }[]; + { seq: number; event_type: string; raw_json: string | null; raw_path: string | null; + bytes: number; retention_status: TraceRetentionStatus }[]; for (const ev of events) { const tool = traceToolEventToEntry(ev); - if (tool) out.push(tool); + if (tool) out.push({ + ...tool, + traceRunId: run.id, + traceSeq: ev.seq, + detailAvailable: run.audience !== 'internal', + detailBytes: ev.bytes, + rawRetentionStatus: run.audience === 'internal' ? 'internal' : ev.retention_status, + }); } } return out; @@ -348,3 +356,15 @@ export function getTraceEvent(runId: string, seq: number): (TraceEventRow & { ra } return { ...row, raw }; } + +/** Best-effort parser recovery after a tool leaves RAM; tracing cannot stop its provider. */ +export function getTraceToolEntry(runId: string, seq: number): ToolEntry | null { + if (!TRACE_ID_RE.test(runId) || !Number.isSafeInteger(seq) || seq < 1) return null; + try { + const row = getEventStmt.get(runId, seq) as TraceEventRow | undefined; + if (!row || row.source !== 'tool') return null; + const tool = traceToolEventToEntry({ raw_json: row.raw_json ?? null, raw_path: row.raw_path ?? null }); + return tool && typeof tool.label === 'string' && typeof tool.icon === 'string' && typeof tool.toolType === 'string' + ? tool : null; + } catch { console.warn('[trace] tool_recovery_unavailable'); return null; } +} diff --git a/tests/unit/print-provider-observation.test.ts b/tests/unit/print-provider-observation.test.ts new file mode 100644 index 000000000..426c72057 --- /dev/null +++ b/tests/unit/print-provider-observation.test.ts @@ -0,0 +1,423 @@ +import '../setup/isolated-home.ts'; +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { extractFromEvent, extractOutputChunk } from '../../src/agent/events/index.ts'; +import { flushOpenCodeBuffers } from '../../src/agent/events/opencode.ts'; +import { FULLTEXT_MAX_CHARS } from '../../src/agent/events/fulltext-bound.ts'; +import type { SpawnContext } from '../../src/types/agent.ts'; +import type { PrintActivityProjection, PrintToolInput } from '../../src/agent/runtime/print-projection.ts'; +import type { CliEventRecord } from '../../src/types/cli-events.ts'; +import { startTraceRun, listToolEntriesForRun, getTraceEvent } from '../../src/trace/store.ts'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.ts'; + +function fixture(cli: string) { + const observed = { + nextMessage: mock.fn(), + message: mock.fn(), + reasoning: mock.fn(), + // Snapshot arguments: parsers mutate the same ToolEntry on completion. + tool: mock.fn((entry: PrintToolInput) => ({ ...entry })), + finish: mock.fn(), + } satisfies PrintActivityProjection; + const ctx: SpawnContext = { + fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), + hasClaudeStreamEvents: false, sessionId: null, cost: null, turns: null, + duration: null, tokens: null, stderrBuf: '', printActivity: observed, + traceRunId: startTraceRun({ cli, audience: 'public' }), + }; + return { ctx, observed, accept: (event: CliEventRecord) => extractFromEvent(cli, event, ctx, cli) }; +} + +const assistant = (id: string, text: string): CliEventRecord => ({ + type: 'assistant', message: { id, content: [{ type: 'text', text }] }, +}); +const stream = (type: string, text: string): CliEventRecord => ({ + type: 'stream_event', event: { type: 'content_block_delta', delta: { + type, ...(type === 'thinking_delta' ? { thinking: text } : { text }), + } }, +}); + +test('Codex messages retain commentary before LAST-WINS and only explicit phases are final', () => { + const { ctx, observed, accept } = fixture('codex'); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Plan', channel: 'commentary' } }); + assert.equal(ctx.fullText, ''); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Unknown' } }); + accept({ type: 'item.completed', item: { type: 'agent_message', text: 'Answer', channel: 'final' } }); + accept({ type: 'turn.completed', usage: { input_tokens: 1 } }); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Plan', 'replace', 'commentary'], ['Unknown', 'replace', 'unknown'], ['Answer', 'replace', 'final'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 3); + assert.equal(observed.tool.mock.callCount(), 0, 'narration cards must not double-observe'); + assert.equal(observed.finish.mock.callCount(), 0, 'only lifecycle owns finish'); + assert.equal(ctx.fullText, 'Answer'); +}); + +test('plain Claude same-ID A/B append while changed-ID text starts a new message', () => { + const { ctx, observed, accept } = fixture('claude'); + accept(assistant('m1', 'A')); + accept(assistant('m1', 'B')); + assert.equal(ctx.fullText, 'A\n- B', 'retain legacy segment formatting'); + assert.equal(observed.nextMessage.mock.callCount(), 0); + accept(assistant('m2', 'Final')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['A', 'append', 'unknown'], ['B', 'append', 'unknown'], ['Final', 'append', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Final'); +}); + +test('Claude text deltas reconcile by replacement; the next stream message resets once', () => { + const { ctx, observed, accept } = fixture('claude'); + accept(stream('text_delta', 'Hel')); + accept(stream('text_delta', 'lo')); + accept(assistant('m1', 'Hello')); + assert.equal(ctx.fullText, 'Hello'); + assert.equal(extractOutputChunk('claude', {}, ctx), 'Hello'); + accept({ type: 'stream_event', event: { type: 'message_start', message: { id: 'm2' } } }); + accept(stream('text_delta', 'Bye')); + accept(assistant('m2', 'Bye')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Hel', 'append', 'unknown'], ['lo', 'append', 'unknown'], ['Hello', 'replace', 'unknown'], + ['Bye', 'append', 'unknown'], ['Bye', 'replace', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Bye'); +}); + +test('claude-e cumulative snapshots replace, reject duplicate/shorter snapshots and reset on ID change', () => { + const { ctx, observed, accept } = fixture('claude-e'); + accept(assistant('m1', 'A')); + accept(assistant('m1', 'AB')); + accept(assistant('m1', 'AB')); + accept(assistant('m1', 'A')); + accept(assistant('m2', 'Answer')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['A', 'replace', 'unknown'], ['AB', 'replace', 'unknown'], ['Answer', 'replace', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, 'Answer'); +}); + +test('Claude thinking deltas are observed once and synthetic flush cards add no tool observation', () => { + const { observed, accept } = fixture('claude'); + accept({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'thinking' } } }); + accept(stream('thinking_delta', 'Think ')); + accept(stream('thinking_delta', 'carefully')); + accept({ type: 'stream_event', event: { type: 'content_block_stop' } }); + assert.deepEqual(observed.reasoning.mock.calls.map(c => c.arguments), [['Think ', 'append'], ['carefully', 'append']]); + assert.equal(observed.tool.mock.callCount(), 0); +}); + +test('complete Codex and nonstream Claude thinking observe accepted plaintext without duplicate tools', () => { + const codex = fixture('codex'); + const reasoning = { type: 'item.completed', item: { type: 'reasoning', text: 'Consider this' } }; + codex.accept(reasoning); + codex.accept(reasoning); + assert.deepEqual(codex.observed.reasoning.mock.calls.map(c => c.arguments), [['Consider this', 'replace']]); + assert.equal(codex.observed.tool.mock.callCount(), 0); + const claude = fixture('claude'); + claude.accept({ type: 'assistant', message: { id: 'thought', content: [{ type: 'thinking', thinking: 'Consider that' }] } }); + assert.deepEqual(claude.observed.reasoning.mock.calls.map(c => c.arguments), [['Consider that', 'replace']]); + assert.equal(claude.observed.tool.mock.callCount(), 0); +}); + +test('Cursor observes only accepted normalized segments and retains pre-tool narration', () => { + const { ctx, observed, accept } = fixture('cursor'); + accept({ type: 'assistant', subtype: 'delta', text: 'Plan\\n' }); + accept({ type: 'assistant', subtype: 'delta', text: 'first' }); + accept(assistant('m1', 'Plan\\nfirst')); + accept({ type: 'tool_call', subtype: 'started', call_id: 'c', name: 'shell' }); + accept(assistant('m1', 'Plan\\nfirst')); + accept(assistant('m1', 'Plan\\nfirst done')); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [ + ['Plan\n', 'append', 'unknown'], ['first', 'append', 'unknown'], [' done', 'append', 'unknown'], + ]); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.equal(ctx.fullText, ' done'); +}); + +test('Cursor message-ID boundary preserves a shared prefix; result fallback remains unknown', () => { + const { ctx, observed, accept } = fixture('cursor'); + accept(assistant('m1', 'Sum')); + accept(assistant('m2', 'Summary')); + assert.equal(observed.nextMessage.mock.callCount(), 1); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [['Sum', 'append', 'unknown'], ['Summary', 'append', 'unknown']]); + assert.equal(ctx.fullText, 'Summary'); + accept({ type: 'result', subtype: 'success', result: 'ignored' }); + assert.equal(observed.message.mock.callCount(), 2); + const fallback = fixture('cursor'); + fallback.accept({ type: 'result', subtype: 'success', result: 'done\\nnext' }); + assert.deepEqual(fallback.observed.message.mock.calls[0]?.arguments, ['done\nnext', 'append', 'unknown']); + assert.equal(fallback.observed.finish.mock.callCount(), 0); +}); + +test('Grok observes complete thought and text chunks before legacy bounds, without final inference', () => { + const { ctx, observed, accept } = fixture('grok'); + const thought = 't'.repeat(102_401); + ctx.fullText = 'x'.repeat(FULLTEXT_MAX_CHARS - 1); + accept({ type: 'thought', data: thought }); + assert.equal(ctx.grokThoughtBuf?.length, 102_400); + accept({ type: 'text', data: 'answer' }); + accept({ type: 'end', stopReason: 'done' }); + assert.deepEqual(observed.reasoning.mock.calls[0]?.arguments, [thought, 'append']); + assert.deepEqual(observed.message.mock.calls[0]?.arguments, ['answer', 'append', 'unknown']); + assert.equal(ctx.fullText.length, FULLTEXT_MAX_CHARS); + assert.equal(ctx.fullTextTruncated, true); + assert.equal(observed.tool.mock.callCount(), 0); + assert.equal(observed.finish.mock.callCount(), 0); +}); + +test('OpenCode retains pre-tool text and reasoning before step discard and reset', () => { + const { ctx, observed, accept } = fixture('opencode'); + accept({ type: 'step_start' }); + accept({ type: 'reasoning', part: { text: 'Reason' } }); + accept({ type: 'text', part: { text: 'Plan' } }); + accept({ type: 'tool_use', part: { tool: 'read', callID: 'c', state: { input: { path: '/tmp/a' } } } }); + accept({ type: 'step_finish', part: { reason: 'tool-calls' } }); + assert.equal(ctx.fullText, ''); + accept({ type: 'step_start' }); + accept({ type: 'text', part: { text: 'Answer' } }); + accept({ type: 'step_finish', part: { reason: 'stop' } }); + assert.equal(ctx.fullText, 'Answer'); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), [['Plan', 'append', 'unknown'], ['Answer', 'append', 'unknown']]); + assert.deepEqual(observed.reasoning.mock.calls.map(c => c.arguments), [['Reason', 'replace']]); + assert.equal(observed.nextMessage.mock.callCount(), 2); + assert.equal(observed.tool.mock.callCount(), 2, 'only the actual tool start and completion'); + assert.equal(observed.finish.mock.callCount(), 0); +}); + +for (const cli of ['cursor', 'grok', 'opencode']) { + test(`${cli} running detail refresh and evicted completion update the same durable pointer once`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (detail: string, done = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: done ? 'completed' : 'started', call_id: 'one', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: done ? 'tool_result' : 'tool_use', id: 'one', name: 'Read', input: { path: detail } } + : { type: 'tool_use', part: { tool: 'Read', callID: 'one', state: { status: done ? 'completed' : 'running', input: { path: detail } } } }; + accept(event('/tmp/old')); + const pointer = { traceRunId: ctx.toolLog[0]?.traceRunId, traceSeq: ctx.toolLog[0]?.traceSeq }; + assert.ok(pointer.traceRunId && pointer.traceSeq); + accept(event('/tmp/new')); + let rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(ctx.toolLog.length, 1); + assert.equal(rows[0]?.detail, '/tmp/new', 'equal counts must still update detail'); + assert.equal(observed.tool.mock.callCount(), 2); + if (cli !== 'grok') { + accept(event('/tmp/new')); + assert.equal(observed.tool.mock.callCount(), 2, 'existing dedupe still rejects exact replay'); + } + ctx.toolLog.length = 0; // emulate the existing RAM cap, preserving stamp-time index + accept(event('/tmp/final', true)); + rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, '/tmp/final'); + assert.equal(rows[0]?.traceSeq, pointer.traceSeq); + assert.equal(observed.tool.mock.callCount(), 3); + assert.equal(observed.tool.mock.calls[2]?.result?.traceSeq, pointer.traceSeq); + }); +} + +test('Claude evicted completion observes after durable update without a new legacy broadcast', () => { + const { ctx, observed, accept } = fixture('claude'); + accept({ type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 'one', name: 'Read', input: { path: '/tmp/a' } }] } }); + const pointer = ctx.toolTraceIndex?.get('claude:tooluse:one'); + assert.ok(pointer); + ctx.toolLog.length = 0; + let legacyCalls = 0; + const listener = (type: string) => { if (type === 'agent_tool') legacyCalls++; }; + observed.tool.mock.mockImplementation(entry => { + const row = getTraceEvent(pointer.traceRunId, pointer.traceSeq); + assert.equal(JSON.parse(row!.raw).status, 'done', 'durable update precedes observer'); + return { ...entry }; + }); + addBroadcastListener(listener); + try { + accept({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'one', content: 'complete' }] } }); + } finally { removeBroadcastListener(listener); } + assert.equal(legacyCalls, 0); + assert.equal(observed.tool.mock.callCount(), 2); + assert.equal(observed.tool.mock.calls[1]?.result?.traceSeq, pointer.traceSeq); + assert.equal(listToolEntriesForRun(ctx.traceRunId!).length, 1); +}); + +test('OpenCode pending-tool flush recovers an evicted pointer and does not re-complete it', () => { + const { ctx, observed, accept } = fixture('opencode'); + accept({ type: 'step_start' }); + accept({ type: 'tool_use', part: { tool: 'Read', callID: 'pending', state: { input: { path: '/tmp/a' } } } }); + const seq = ctx.toolLog[0]?.traceSeq; + ctx.toolLog.length = 0; + flushOpenCodeBuffers(ctx, 'opencode'); + flushOpenCodeBuffers(ctx, 'opencode'); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.traceSeq, seq); + assert.equal(observed.tool.mock.callCount(), 2); +}); + +test('Grok throttled thought detail and evicted thought completion converge durably', () => { + const { ctx, observed, accept } = fixture('grok'); + accept({ type: 'thought', data: 'A' }); + ctx.grokLastThoughtEmitAt = Date.now() + 60_000; + accept({ type: 'thought', data: 'B' }); + assert.equal(listToolEntriesForRun(ctx.traceRunId!)[0]?.detail, 'AB'); + ctx.toolLog.length = 0; + accept({ type: 'end' }); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, 'AB'); + assert.equal(observed.tool.mock.callCount(), 0); +}); + +for (const cli of ['cursor', 'grok', 'opencode']) { + test(`${cli} same-label calls keep distinct pointers through running eviction and error completion`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (id: string, detail: string, failed = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: failed ? 'error' : 'started', call_id: id, name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: failed ? 'tool_result' : 'tool_use', id, name: 'Read', input: { path: detail }, ...(failed ? { is_error: true } : {}) } + : { type: 'tool_use', part: { tool: 'Read', callID: id, state: { status: failed ? 'error' : 'running', input: { path: detail } } } }; + accept(event('first', '/tmp/first')); + accept(event('second', '/tmp/second')); + const firstSeq = ctx.toolLog[0]?.traceSeq; + const secondSeq = ctx.toolLog[1]?.traceSeq; + assert.notEqual(firstSeq, secondSeq); + ctx.toolLog.length = 0; + accept(event('first', '/tmp/refreshed')); + assert.equal(listToolEntriesForRun(ctx.traceRunId!)[0]?.detail, '/tmp/refreshed'); + ctx.toolLog.length = 0; + accept(event('second', '/tmp/failed', true)); + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 2, 'a label is never a new identity index'); + assert.equal(rows[0]?.traceSeq, firstSeq); + assert.equal(rows[1]?.traceSeq, secondSeq); + assert.equal(rows[1]?.status, 'error'); + assert.equal(rows[1]?.detail, '/tmp/failed'); + assert.equal(observed.tool.mock.callCount(), 4); + }); +} + +for (const cli of ['cursor', 'grok', 'opencode']) { + for (const status of ['done', 'error'] as const) { + for (const evicted of [false, true]) { + test(`${cli} start(old) -> ${status}(final) -> start(old) preserves terminal ${evicted ? 'after eviction' : 'in RAM'}`, () => { + const { ctx, observed, accept } = fixture(cli); + const event = (detail: string, terminal = false): CliEventRecord => cli === 'cursor' + ? { type: 'tool_call', subtype: terminal ? (status === 'done' ? 'completed' : 'error') : 'started', + call_id: 'stale', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: terminal ? 'tool_result' : 'tool_use', id: 'stale', name: 'Read', + input: { path: detail }, ...(terminal && status === 'error' ? { is_error: true } : {}) } + : { type: 'tool_use', part: { tool: 'Read', callID: 'stale', state: { + status: terminal ? (status === 'done' ? 'completed' : 'error') : 'running', input: { path: detail }, + } } }; + accept(event('/tmp/old')); + accept(event('/tmp/final', true)); + const terminalRows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(terminalRows.length, 1); + assert.equal(terminalRows[0]?.status, status); + assert.equal(terminalRows[0]?.detail, '/tmp/final'); + if (evicted) ctx.toolLog.length = 0; + const terminalRam = ctx.toolLog.map(entry => ({ ...entry })); + accept(event('/tmp/old')); + assert.deepEqual(listToolEntriesForRun(ctx.traceRunId!), terminalRows, 'stale start must not write durable state'); + assert.deepEqual(ctx.toolLog, terminalRam, 'stale start must not mutate or repopulate RAM'); + assert.equal(observed.tool.mock.callCount(), 2, 'stale start must not reach the observer'); + // A newer terminal detail remains admissible; the guard is not a blanket freeze. + accept(event('/tmp/final-updated', true)); + const updated = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(updated.length, 1); + assert.equal(updated[0]?.status, status); + assert.equal(updated[0]?.detail, '/tmp/final-updated'); + assert.equal(updated[0]?.traceSeq, terminalRows[0]?.traceSeq); + assert.equal(observed.tool.mock.callCount(), 3); + }); + } + } +} + +for (const evicted of [false, true]) { + test(`Cursor done -> answer -> stale start preserves answer and message boundary ${evicted ? 'after eviction' : 'in RAM'}`, () => { + const { ctx, observed, accept } = fixture('cursor'); + const start: CliEventRecord = { type: 'tool_call', subtype: 'started', call_id: 'old', name: 'Read', input: { path: '/tmp/old' } }; + accept(start); + accept({ ...start, subtype: 'completed', input: { path: '/tmp/final' } }); + accept(assistant('answer', 'Final answer')); + assert.equal(ctx.fullText, 'Final answer'); + const answer = { + fullText: ctx.fullText, outputTextStarted: ctx.outputTextStarted, + pendingOutputChunk: ctx.pendingOutputChunk, + cursorAssistantText: ctx.cursorAssistantText, cursorAssistantMessageId: ctx.cursorAssistantMessageId, + }; + const boundaries = observed.nextMessage.mock.callCount(); + const messages = observed.message.mock.calls.map(c => c.arguments); + const terminalRows = listToolEntriesForRun(ctx.traceRunId!); + if (evicted) ctx.toolLog.length = 0; + const terminalRam = ctx.toolLog.map(entry => ({ ...entry })); + accept(start); + assert.equal(ctx.fullText, 'Final answer', 'stale start must not erase the legacy answer'); + assert.deepEqual({ + fullText: ctx.fullText, outputTextStarted: ctx.outputTextStarted, + pendingOutputChunk: ctx.pendingOutputChunk, + cursorAssistantText: ctx.cursorAssistantText, cursorAssistantMessageId: ctx.cursorAssistantMessageId, + }, answer); + assert.equal(observed.nextMessage.mock.callCount(), boundaries, 'current print message must not advance'); + assert.deepEqual(observed.message.mock.calls.map(c => c.arguments), messages); + assert.deepEqual(ctx.toolLog, terminalRam); + assert.deepEqual(listToolEntriesForRun(ctx.traceRunId!), terminalRows); + assert.equal(observed.tool.mock.callCount(), 2); + // A genuinely new tool still clears preceding text and advances the message. + accept({ ...start, call_id: 'new' }); + assert.equal(ctx.fullText, ''); + assert.equal(ctx.outputTextStarted, false); + assert.equal(ctx.cursorAssistantText, 'Final answer', 'keep the cumulative dedupe baseline'); + assert.equal(observed.nextMessage.mock.callCount(), boundaries + 1); + assert.equal(observed.tool.mock.callCount(), 3); + assert.equal(listToolEntriesForRun(ctx.traceRunId!).length, 2); + }); +} + +for (const cli of ['cursor', 'grok', 'opencode', 'claude']) { + test(`${cli} spilled tool read failure cannot escape completion or erase the legacy answer`, () => { + const { ctx, observed, accept } = fixture(cli); + const detail = '/tmp/' + 'x'.repeat(96_001); + const start: CliEventRecord = cli === 'cursor' + ? { type: 'tool_call', subtype: 'started', call_id: 'spill', name: 'Read', input: { path: detail } } + : cli === 'grok' + ? { type: 'tool_use', id: 'spill', name: 'Read', input: { path: detail } } + : cli === 'opencode' + ? { type: 'tool_use', part: { tool: 'Read', callID: 'spill', state: { input: { path: detail } } } } + : { type: 'assistant', message: { id: 'm1', content: [{ type: 'tool_use', id: 'spill', name: 'Read', input: { path: detail } }] } }; + accept(start); + const pointer = { traceRunId: ctx.toolLog[0]?.traceRunId, traceSeq: ctx.toolLog[0]?.traceSeq }; + assert.ok(pointer.traceRunId && pointer.traceSeq); + assert.ok(getTraceEvent(pointer.traceRunId, pointer.traceSeq)?.raw_path, 'exercise a real spilled row'); + ctx.toolLog.length = 0; + ctx.fullText = 'Existing legacy answer'; + const complete: CliEventRecord = cli === 'cursor' + ? { ...start, subtype: 'completed', input: { path: '/tmp/final' } } + : cli === 'grok' + ? { type: 'tool_result', id: 'spill', name: 'Read', input: { path: '/tmp/final' } } + : cli === 'opencode' + ? { type: 'tool_use', part: { tool: 'Read', callID: 'spill', state: { status: 'completed', input: { path: '/tmp/final' } } } } + : { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'spill', content: '/tmp/final' }] } }; + const brokenRead = mock.method(fs, 'readFileSync', () => { throw new Error('injected spill read failure'); }); + try { + assert.doesNotThrow(() => accept(complete)); + assert.equal(brokenRead.mock.callCount(), 1, 'parser attempted the failing spill read'); + } finally { brokenRead.mock.restore(); } + const rows = listToolEntriesForRun(ctx.traceRunId!); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.traceSeq, pointer.traceSeq); + assert.equal(rows[0]?.status, 'done'); + assert.equal(rows[0]?.detail, '/tmp/final', 'accepted payload supplies terminal detail without recovered raw'); + assert.equal(ctx.fullText, 'Existing legacy answer'); + assert.equal(observed.tool.mock.callCount(), 2); + assert.equal(observed.tool.mock.calls[1]?.result?.status, 'done'); + }); +} From 8611a81be517094fa7177fe927085e96d828d9e9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:03:02 +0900 Subject: [PATCH 17/33] fix: merge latest tool state without blocking final delivery --- src/agent/lifecycle-handler.ts | 41 ++---- src/agent/merge-tool-log.ts | 78 +++++++++++ src/routes/messages.ts | 13 +- src/routes/orchestrate.ts | 23 ++- tests/unit/live-run-trace-hydration.test.ts | 101 +++++++++++-- tests/unit/merge-tool-log.test.ts | 148 ++++++++++++++++++++ tests/unit/print-activity-lifecycle.test.ts | 124 ++++++++++++++++ tests/unit/trace-message-hydration.test.ts | 66 ++++++++- 8 files changed, 535 insertions(+), 59 deletions(-) create mode 100644 src/agent/merge-tool-log.ts create mode 100644 tests/unit/merge-tool-log.test.ts create mode 100644 tests/unit/print-activity-lifecycle.test.ts diff --git a/src/agent/lifecycle-handler.ts b/src/agent/lifecycle-handler.ts index fe0fd27ac..b493e2d6f 100644 --- a/src/agent/lifecycle-handler.ts +++ b/src/agent/lifecycle-handler.ts @@ -20,6 +20,7 @@ import { clearLiveRun, getLiveRun } from './live-run-state.js'; import { sanitizeToolLogForDurableStorage, serializeSanitizedToolLog } from '../shared/tool-log-sanitize.js'; import { scanStructuredFence } from '../shared/structured-fence.js'; import { finalizeTraceRun, linkTraceRunToMessage } from '../trace/store.js'; +import { mergeLatestTools } from './merge-tool-log.js'; import type { TraceRunStatus } from '../trace/types.js'; import type { RuntimeEventBody, RuntimeTransport, RuntimeTurnOutcome } from '../shared/runtime-contract.js'; import { lifecycleRuntimeOutcome, runtimeOutcomeExitCode } from './runtime/outcome.js'; @@ -363,8 +364,10 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise }); } catch { console.warn('[runtime:projection] lifecycle observer failed'); } } - if (nativeOutcome === undefined) finalizeTraceRun(ctx.traceRunId, status, error); - else { + if (nativeOutcome === undefined) { + try { finalizeTraceRun(ctx.traceRunId, status, error); } + catch { console.warn('[trace] print finalization failed'); } + } else { try { finalizeTraceRun(nativeTraceRunId, status, error); } catch { console.warn('[runtime] outcome trace finalization failed'); } } @@ -640,14 +643,9 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise if (nativeOutcome !== undefined) { let finalContent = nativeOutcome.finalText; if (mainManaged && !opts.internal) { - const seen = new Set(); - const combined: unknown[] = []; - for (const tool of [...ctx.toolLog, ...getLiveRun(liveScope).toolLog]) { - if (tool.stepRef && seen.has(tool.stepRef)) continue; - if (tool.stepRef) seen.add(tool.stepRef); - combined.push(tool); - } - const safeTools = sanitizeToolLogForDurableStorage(combined); + const safeTools = sanitizeToolLogForDurableStorage( + mergeLatestTools(ctx.toolLog, getLiveRun(liveScope).toolLog, nativeTraceRunId || ''), + ); if (finalContent !== null) { finalContent = applyOutputPolicy(finalContent, { scope: 'main' }).text; evaluateRecordPending(ctx.toolLog, finalContent); @@ -773,21 +771,9 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise }); } const liveRun = getLiveRun(liveScope); - // Union, not pick-one: boss tools (ctx.toolLog) + worker mirrors (liveRun.toolLog, - // preserved across syncs by replaceLiveRunTools). Key on stepRef (durable, never - // stripped by the sanitizer); identity-less entries append (no false dedup). The - // old ternary kept only the longer array and discarded the other, dropping worker - // mirrors whenever the boss tool log was longer (claude). (devlog 260620 R1.) - const unionSeen = new Set(); - const unionToolLog: unknown[] = []; - const pushUnionTool = (t: { stepRef?: unknown }): void => { - const ref = typeof t.stepRef === 'string' && t.stepRef ? t.stepRef : null; - if (ref) { if (unionSeen.has(ref)) return; unionSeen.add(ref); } - unionToolLog.push(t); - }; - for (const t of ctx.toolLog) pushUnionTool(t); - for (const t of liveRun.toolLog) pushUnionTool(t); - const sanitizedToolLog = sanitizeToolLogForDurableStorage(unionToolLog); + const sanitizedToolLog = sanitizeToolLogForDurableStorage( + mergeLatestTools(ctx.toolLog, liveRun.toolLog, ctx.traceRunId || ''), + ); const toolLogJson = serializeSanitizedToolLog(sanitizedToolLog); const info = insertMessageWithTraceRun.run( 'assistant', finalContent, cli, model, @@ -795,7 +781,10 @@ export async function handleAgentExit(params: ExitHandlerParams): Promise ctx.traceRunId || null, chatSessionId, ); const messageId = Number(info.lastInsertRowid || 0); - if (ctx.traceRunId && Number.isInteger(messageId) && messageId > 0) linkTraceRunToMessage(ctx.traceRunId, messageId); + if (ctx.traceRunId && Number.isInteger(messageId) && messageId > 0) { + try { linkTraceRunToMessage(ctx.traceRunId, messageId); } + catch { console.warn('[trace] print link failed'); } + } broadcast('agent_done', { ...runTag(ctx), text: finalContent, toolLog: sanitizedToolLog, origin, ...empTag, ...(wasSteer ? { steered: true } : {}) }); if (opts._heartbeatAnchorId) { diff --git a/src/agent/merge-tool-log.ts b/src/agent/merge-tool-log.ts new file mode 100644 index 000000000..85be43cce --- /dev/null +++ b/src/agent/merge-tool-log.ts @@ -0,0 +1,78 @@ +import type { ToolEntry } from '../types/agent.js'; +import { isToolLogOverflowMarker, type SanitizedToolLogEntry } from '../shared/tool-log-sanitize.js'; + +function toolIdentity(tool: ToolEntry, fallbackRunId: string): string | undefined { + // A boss run cannot supply the missing identity of a worker's child run. + const runId = tool.traceRunId || (tool.isEmployee ? '' : fallbackRunId); + if (!runId) return undefined; + if (tool.stepRef) return JSON.stringify([runId, 'ref', tool.stepRef]); + if (Number.isSafeInteger(tool.traceSeq) && tool.traceSeq! > 0) { + return JSON.stringify([runId, 'seq', tool.traceSeq]); + } + return undefined; +} + +const TERMINAL_STATUSES = new Set(['done', 'error', 'failed', 'completed', 'cancelled', 'canceled', 'interrupted', 'stopped']); + +function statusRank(tool: ToolEntry): number { + if (tool.status && TERMINAL_STATUSES.has(tool.status)) return 2; + return tool.status === 'running' ? 1 : 0; +} + +function latestTool(prior: ToolEntry, incoming: ToolEntry, preferIncoming: boolean): ToolEntry { + const difference = statusRank(incoming) - statusRank(prior); + const incomingWins = difference > 0 || (difference === 0 && preferIncoming); + const [winner, other] = incomingWins ? [incoming, prior] : [prior, incoming]; + const detail = winner.detail !== undefined ? winner.detail : other.detail; + return { + ...other, + ...winner, + // Empty is an explicit clear; missing detail can borrow the other snapshot. + ...(detail !== undefined ? { detail } : {}), + }; +} + +function foldSource(entries: readonly (ToolEntry | SanitizedToolLogEntry)[], fallbackRunId: string): ToolEntry[] { + const result: ToolEntry[] = []; + const positions = new Map(); + for (const entry of entries) { + if (isToolLogOverflowMarker(entry)) continue; + const tool: ToolEntry = { ...entry, toolType: entry.toolType ?? 'tool' }; + const key = toolIdentity(tool, fallbackRunId); + const position = key === undefined ? undefined : positions.get(key); + if (position === undefined) { + if (key !== undefined) positions.set(key, result.length); + result.push(tool); + } else { + result[position] = latestTool(result[position]!, tool, true); + } + } + return result; +} + +/** Latest content within each source; primary wins ties across sources. Positions + * stay primary-first, then mirror-only. Entries without a scoped identity remain + * distinct: labels and array positions are never used to infer tool ownership. + * Omission markers are separate from tools: keep one authoritative marker at the + * head so the consumer's sanitizer can absorb it and account for any new caps. */ +export function mergeLatestTools( + primary: readonly ToolEntry[], + mirrors: readonly SanitizedToolLogEntry[], + fallbackRunId: string, +): ToolEntry[] { + const marker = primary.find(isToolLogOverflowMarker) ?? mirrors.find(isToolLogOverflowMarker); + const result = foldSource(primary, fallbackRunId); + const positions = new Map(); + result.forEach((tool, position) => { + const key = toolIdentity(tool, fallbackRunId); + if (key !== undefined) positions.set(key, position); + }); + for (const tool of foldSource(mirrors, fallbackRunId)) { + const key = toolIdentity(tool, fallbackRunId); + const position = key === undefined ? undefined : positions.get(key); + if (position === undefined) result.push(tool); + else result[position] = latestTool(result[position]!, tool, false); + } + // Source omission counts may overlap; never invent a sum across snapshots. + return marker ? [{ ...marker, toolType: marker.toolType ?? 'tool' }, ...result] : result; +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index b82226c3c..19d509463 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -16,6 +16,7 @@ import { dashboardActivityTitleFromExcerpt } from '../core/message-summary.js'; import { sanitizeSerializedToolLog, serializeSanitizedToolLog, parseToolLogBounded } from '../shared/tool-log-sanitize.js'; import { isAgentBusy } from '../agent/spawn.js'; import { listToolEntriesForMessage } from '../trace/store.js'; +import { mergeLatestTools } from '../agent/merge-tool-log.js'; import { HYDRATE_TOOL_CARDS_FROM_TRACE } from '../core/config.js'; // Option D (devlog 260620 Phase 3): tool cards for a finished message come from @@ -32,20 +33,12 @@ export function resolveToolLog( if (traceTools.length) { // Boss tools come from trace_events (durable, uncapped). Worker mirrors // (isEmployee) stay sourced from the blob, where Phase 1 already preserves them - // sanitized — so enabling the flag never drops worker cards. Union by stepRef. + // sanitized — so enabling the flag never drops worker cards. // (Folding worker child runs from trace via parent_run_id is the purer Option D // path but needs a cross-process linkage write; the blob mirror is display- // equivalent and ships the flag safely now — devlog 260620 doc 20/31.) const blobWorkers = parseToolLogBounded(blobToolLog).filter((t) => t.isEmployee === true); - if (!blobWorkers.length) return serializeSanitizedToolLog(traceTools); - const seen = new Set(); - const merged: unknown[] = []; - for (const t of [...traceTools, ...blobWorkers] as { stepRef?: unknown }[]) { - const ref = typeof t.stepRef === 'string' && t.stepRef ? t.stepRef : null; - if (ref) { if (seen.has(ref)) continue; seen.add(ref); } - merged.push(t); - } - return serializeSanitizedToolLog(merged); + return serializeSanitizedToolLog(mergeLatestTools(traceTools, blobWorkers, '')); } } return sanitizeSerializedToolLog(blobToolLog); diff --git a/src/routes/orchestrate.ts b/src/routes/orchestrate.ts index 01c9ccb4e..f833aec63 100644 --- a/src/routes/orchestrate.ts +++ b/src/routes/orchestrate.ts @@ -3,7 +3,8 @@ import type { AuthMiddleware } from './types.js'; import { fail } from '../http/response.js'; import { isAgentBusy, messageQueue, getQueuedMessageSnapshotForScope, removeQueuedMessage, killActiveAgent, waitForProcessEnd, waitForExitSettled, getCurrentMainMeta, getSteerWaitMsForActiveAgent, setQueueHold, clearQueueHold, setSteerInProgress, isSteerInProgress } from '../agent/spawn.js'; import { getLiveRun } from '../agent/live-run-state.js'; -import { countToolTraceRows, listToolEntriesForRun } from '../trace/store.js'; +import { listToolEntriesForRun } from '../trace/store.js'; +import { mergeLatestTools } from '../agent/merge-tool-log.js'; import { orchestrate, orchestrateContinue, orchestrateReset, isResetIntent, isContinueIntent, drainPendingReplays } from '../orchestrator/pipeline.js'; import { getSession, insertMessage } from '../core/db.js'; import { getActiveChatSession } from '../core/chat-sessions.js'; @@ -76,17 +77,15 @@ function getSafeLiveRun(scope: string) { const liveRun = getLiveRun(scope); let toolLog = sanitizeToolLogForDurableStorage(liveRun.toolLog); if (liveRun.running && liveRun.traceRunId) { - const bossCount = toolLog.filter(t => t.isEmployee !== true && !isToolLogOverflowMarker(t)).length; - const ramBehind = toolLog.length === 0 - || toolLog.some(isToolLogOverflowMarker) - || countToolTraceRows(liveRun.traceRunId) > bossCount; - if (ramBehind) { - const boss = listToolEntriesForRun(liveRun.traceRunId); - if (boss.length > bossCount) { - const mirrors = toolLog.filter(t => t.isEmployee === true); - toolLog = sanitizeToolLogForDurableStorage([...boss, ...mirrors]); - } - } + // Updates replace rows in place, so equal counts still need a durable read. + // All RAM entries remain fallback when storage missed a tool, including boss tools. + const boss = listToolEntriesForRun(liveRun.traceRunId, 400); + // Durable reconstruction recalculates omissions; an old RAM marker is not a tool. + // Without durable rows, retain the marker documenting RAM-only history loss. + const mirrors = boss.length + ? liveRun.toolLog.filter(tool => !isToolLogOverflowMarker(tool)) + : liveRun.toolLog; + toolLog = sanitizeToolLogForDurableStorage(mergeLatestTools(boss, mirrors, liveRun.traceRunId)); } return { ...liveRun, toolLog }; } diff --git a/tests/unit/live-run-trace-hydration.test.ts b/tests/unit/live-run-trace-hydration.test.ts index 92fd464a4..b8cb0f6c3 100644 --- a/tests/unit/live-run-trace-hydration.test.ts +++ b/tests/unit/live-run-trace-hydration.test.ts @@ -1,13 +1,11 @@ -// WP4 (devlog 260703 doc 12): /api/orchestrate/snapshot falls back to durable -// trace_events tool rows when the in-RAM live-run toolLog is empty or behind, -// preserves RAM-only isEmployee mirrors, and passes RAM through untouched when -// it is healthy. Route-level harness per tests/unit/trace-routes.test.ts. +// Snapshots converge from durable rows even at equal counts, preserving RAM fallback. +import '../setup/test-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer, type Server } from 'node:http'; import express, { type NextFunction, type Request, type Response } from 'express'; import { registerOrchestrateRoutes } from '../../src/routes/orchestrate.ts'; -import { startTraceRun, stampTraceTool } from '../../src/trace/store.ts'; +import { startTraceRun, stampTraceTool, updateTraceToolRow, countToolTraceRows } from '../../src/trace/store.ts'; import { beginLiveRun, setLiveRunTraceId, appendLiveRunTool, clearLiveRun } from '../../src/agent/live-run-state.ts'; import type { ToolEntry } from '../../src/types/agent.ts'; @@ -73,18 +71,101 @@ test('snapshot hydration preserves RAM-only isEmployee mirror entries', async () clearLiveRun(SCOPE); }); -test('snapshot passes the RAM toolLog through when RAM is healthy (no fallback)', async () => { +// Intentionally replaces the old equal-count => RAM-label-wins expectation (devlog 030/031). +// Equal row counts do not prove freshness: updateTraceToolRow changes the existing SQL row. +// This strengthens the behavioral oracle: assert durable terminal status, updated detail, +// and an explicit detail clear through the snapshot route, while the row count stays one. +test('snapshot observes equal-count durable terminal and detail updates', async () => { clearLiveRun(SCOPE); const runId = startTraceRun({ cli: 'claude', audience: 'public' }); beginLiveRun(SCOPE, 'claude'); setLiveRunTraceId(SCOPE, runId); - // One trace row and one RAM entry with a DIFFERENT label: counts are equal, so - // hydration must NOT replace RAM — the RAM version is what the snapshot returns. - const tool: ToolEntry = { icon: '🔧', label: 'trace-version', toolType: 'tool' }; + const tool: ToolEntry = { icon: '🔧', label: 'working', toolType: 'tool', status: 'running', detail: 'started' }; stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }, 'tool'); appendLiveRunTool(SCOPE, { ...tool, label: 'ram-version' }); + updateTraceToolRow({ ...tool, label: 'finished', status: 'done', detail: 'result' }); + assert.equal(countToolTraceRows(runId), 1, 'completion updates the same SQL row'); const activeRun = await fetchActiveRun(); - assert.deepEqual(activeRun.toolLog.map(t => t.label), ['ram-version']); + assert.deepEqual(activeRun.toolLog.map(t => [t.label, t.status, t.detail]), [['finished', 'done', 'result']]); + updateTraceToolRow({ ...tool, label: 'finished', status: 'done', detail: '' }); + const cleared = await fetchActiveRun(); + assert.equal(cleared.toolLog.length, 1); + assert.equal(cleared.toolLog[0]?.detail ?? '', '', 'empty durable detail must not resurrect stale RAM detail'); + clearLiveRun(SCOPE); +}); + +test('snapshot keeps all RAM tools when storage has no rows or only some rows', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + appendLiveRunTool(SCOPE, { icon: '🔧', label: 'unstored-boss', toolType: 'tool', stepRef: 'lost' }); + appendLiveRunTool(SCOPE, { icon: '🤖', label: 'unstored-worker', toolType: 'tool', isEmployee: true }); + assert.equal(countToolTraceRows(runId), 0); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['unstored-boss', 'unstored-worker']); + seedTraceTools(runId, ['stored-boss']); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['stored-boss', 'unstored-boss', 'unstored-worker']); + setLiveRunTraceId(SCOPE, 'tr_missing000000000000000000'); + assert.deepEqual((await fetchActiveRun()).toolLog.map(t => t.label), ['unstored-boss', 'unstored-worker']); + clearLiveRun(SCOPE); +}); + +test('snapshot preserves cross-run and unscoped workers with the boss stepRef', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + const workerRunId = startTraceRun({ cli: 'claude', audience: 'internal' }); + const boss: ToolEntry = { icon: '🔧', label: 'boss', toolType: 'tool', stepRef: 'shared', status: 'done' }; + stampTraceTool(boss, { traceRunId: runId, traceAudience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + appendLiveRunTool(SCOPE, { ...boss, label: 'stale-boss', status: 'running' }); + appendLiveRunTool(SCOPE, { ...boss, traceRunId: workerRunId, label: 'worker', isEmployee: true }); + appendLiveRunTool(SCOPE, { icon: '🤖', label: 'unknown-worker', toolType: 'tool', stepRef: 'shared', isEmployee: true }); + const tools = (await fetchActiveRun()).toolLog; + assert.deepEqual(tools.map(t => t.label), ['boss', 'worker', 'unknown-worker']); + assert.equal(tools[0]?.status, 'done'); + clearLiveRun(SCOPE); +}); + +test('snapshot reads only the latest 400 durable rows before applying inline caps', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + seedTraceTools(runId, Array.from({ length: 405 }, (_, i) => `boss-${i + 1}`)); + beginLiveRun(SCOPE, 'claude'); + setLiveRunTraceId(SCOPE, runId); + const tools = (await fetchActiveRun()).toolLog; + assert.equal(tools.length, 160); + assert.equal(tools[0]?.label, '241 tool events omitted', 'inline cap saw 400 rows, not the whole run'); + assert.equal(tools[1]?.label, 'boss-247'); + assert.equal(tools.at(-1)?.label, 'boss-405'); + clearLiveRun(SCOPE); +}); + +test('snapshot recalculates one omission marker for 161 durable tools and preserves it without DB rows', async () => { + clearLiveRun(SCOPE); + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + beginLiveRun(SCOPE, 'claude'); + for (let seq = 1; seq <= 161; seq++) { + const tool: ToolEntry = { icon: '🔧', label: `boss-${seq}`, toolType: 'tool', status: 'done' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + appendLiveRunTool(SCOPE, tool); + } + assert.equal(countToolTraceRows(runId), 161); + const newestSequences = Array.from({ length: 159 }, (_, i) => i + 3); + // With no durable rows, the RAM marker still accounts for its two evicted tools. + setLiveRunTraceId(SCOPE, 'tr_missing000000000000000000'); + const fallback = (await fetchActiveRun()).toolLog; + assert.equal(fallback.length, 160); + assert.equal(fallback[0]?.label, '2 tool events omitted'); + assert.deepEqual(fallback.slice(1).map(t => t.traceSeq), newestSequences); + + // Reconstructing from all durable tools must replace, not append, the RAM marker. + setLiveRunTraceId(SCOPE, runId); + const reconstructed = (await fetchActiveRun()).toolLog; + assert.equal(reconstructed.length, 160); + assert.equal(reconstructed.filter(t => t.traceSeq === undefined).length, 1, 'exactly one omission marker'); + assert.equal(reconstructed[0]?.label, '2 tool events omitted'); + assert.deepEqual(reconstructed.slice(1).map(t => t.traceSeq), newestSequences, 'all 159 newest real tools survive'); clearLiveRun(SCOPE); }); diff --git a/tests/unit/merge-tool-log.test.ts b/tests/unit/merge-tool-log.test.ts new file mode 100644 index 000000000..48fbe8ef5 --- /dev/null +++ b/tests/unit/merge-tool-log.test.ts @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mergeLatestTools } from '../../src/agent/merge-tool-log.js'; +import type { ToolEntry } from '../../src/types/agent.js'; +import type { SanitizedToolLogEntry } from '../../src/shared/tool-log-sanitize.js'; +import { sanitizeToolLogForDurableStorage } from '../../src/shared/tool-log-sanitize.js'; + +function tool(label: string, fields: Partial = {}): ToolEntry { + return { icon: '🔧', label, toolType: 'tool', ...fields }; +} + +test('run/ref and run/seq identity domains cannot collide, including delimiter-like refs', () => { + const result = mergeLatestTools([ + tool('ref', { traceRunId: 'run', stepRef: 'trace:2' }), + tool('seq', { traceRunId: 'run', traceSeq: 2 }), + tool('other-run', { traceRunId: 'other', stepRef: 'trace:2' }), + tool('delimiter-ref', { traceRunId: 'run', stepRef: 'x:ref:y' }), + tool('delimiter-run', { traceRunId: 'run:ref:x', stepRef: 'y' }), + ], [tool('latest-ref', { traceRunId: 'run', stepRef: 'trace:2', status: 'done' })], 'fallback'); + assert.deepEqual(result.map(t => t.label), ['latest-ref', 'seq', 'other-run', 'delimiter-ref', 'delimiter-run']); +}); + +test('fallback scopes boss entries but never unscoped workers; unknown runs stay distinct', () => { + const result = mergeLatestTools([ + tool('boss', { stepRef: 'same' }), + tool('worker-a', { stepRef: 'same', isEmployee: true }), + tool('worker-b', { stepRef: 'same', isEmployee: true }), + ], [ + tool('boss-done', { traceRunId: 'boss-run', stepRef: 'same', status: 'done' }), + tool('child', { traceRunId: 'child-run', stepRef: 'same', isEmployee: true }), + tool('unknown-child', { stepRef: 'same', isEmployee: true }), + ], 'boss-run'); + assert.deepEqual(result.map(t => t.label), ['boss-done', 'worker-a', 'worker-b', 'child', 'unknown-child']); + assert.equal(mergeLatestTools([tool('unknown', { stepRef: 'same' })], [tool('unknown', { stepRef: 'same' })], '').length, 2); +}); + +test('identityless entries occur once per input occurrence, without merging by label', () => { + const anonymous = tool('anonymous'); + const result = mergeLatestTools([anonymous, { ...anonymous }], [{ ...anonymous }], 'run'); + assert.equal(result.length, 3); + assert.deepEqual(result.map(t => t.label), ['anonymous', 'anonymous', 'anonymous']); + assert.equal(mergeLatestTools([anonymous], [], 'run').length, 1, 'primary anonymous is not appended twice'); + assert.equal(mergeLatestTools([], [anonymous], 'run').length, 1); + assert.deepEqual(mergeLatestTools([], [], ''), []); +}); + +test('invalid seq pointers do not identify tools; a nonempty ref takes precedence over seq', () => { + for (const traceSeq of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.equal(mergeLatestTools([tool('a', { traceSeq })], [tool('b', { traceSeq })], 'run').length, 2); + } + const result = mergeLatestTools([ + tool('a', { stepRef: 'a', traceSeq: 1 }), tool('b', { stepRef: 'b', traceSeq: 1 }), + ], [tool('a-done', { stepRef: 'a', traceSeq: 2, status: 'done' })], 'run'); + assert.deepEqual(result.map(t => t.label), ['a-done', 'b']); +}); + +test('latest within each source wins equal status while primary wins source ties and order', () => { + const result = mergeLatestTools([ + tool('boss-old', { stepRef: 'boss', status: 'running' }), + tool('boss-second', { stepRef: 'second', status: 'done' }), + tool('boss-latest', { stepRef: 'boss', status: 'running' }), + ], [ + tool('worker-old', { traceRunId: 'child', stepRef: 'worker', isEmployee: true, status: 'done' }), + tool('mirror-boss', { stepRef: 'boss', status: 'running' }), + tool('worker-latest', { traceRunId: 'child', stepRef: 'worker', isEmployee: true, status: 'done' }), + ], 'run'); + assert.deepEqual(result.map(t => t.label), ['boss-latest', 'boss-second', 'worker-latest']); +}); + +test('terminal status never regresses within either source or across sources', () => { + for (const status of ['done', 'error', 'failed', 'completed', 'cancelled', 'canceled', 'interrupted', 'stopped']) { + const terminal = tool('terminal', { stepRef: 'same', status, detail: 'final result' }); + const running = tool('stale', { stepRef: 'same', status: 'running', detail: 'in progress' }); + for (const [primary, mirrors] of [ + [[terminal, running], []], [[], [terminal, running]], + [[terminal], [running]], [[running], [terminal]], + ] as [ToolEntry[], ToolEntry[]][]) { + const result = mergeLatestTools(primary, mirrors, 'run'); + assert.equal(result.length, 1); + assert.equal(result[0]?.status, status); + assert.equal(result[0]?.detail, 'final result'); + } + } +}); + +test('explicit empty detail clears old content; undefined preserves prior detail', () => { + const old = tool('old', { stepRef: 'same', status: 'done', detail: 'old detail' }); + const empty = tool('empty', { stepRef: 'same', status: 'done', detail: '' }); + const missing = tool('missing', { stepRef: 'same', status: 'done' }); + assert.equal(Object.hasOwn(mergeLatestTools([missing], [missing], 'run')[0]!, 'detail'), false); + assert.equal(mergeLatestTools([old, empty], [], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([old, missing], [], 'run')[0]?.detail, 'old detail'); + assert.equal(mergeLatestTools([empty], [old], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([missing], [old, empty], 'run')[0]?.detail, ''); + assert.equal(mergeLatestTools([missing], [old, missing], 'run')[0]?.detail, 'old detail'); + assert.equal(mergeLatestTools([old], [empty], 'run')[0]?.detail, 'old detail', 'primary wins equal status'); + assert.equal(mergeLatestTools([tool('running', { stepRef: 'same', status: 'running', detail: 'old' })], [empty], 'run')[0]?.detail, ''); +}); + +test('latest mirror detail fills a primary omission without replacing primary label', () => { + const result = mergeLatestTools([tool('primary', { stepRef: 'same', status: 'done' })], [ + tool('mirror-old', { stepRef: 'same', status: 'done', detail: 'first' }), + tool('mirror-latest', { stepRef: 'same', status: 'done', detail: 'second' }), + ], 'run'); + assert.equal(result[0]?.label, 'primary'); + assert.equal(result[0]?.detail, 'second'); +}); + +test('readonly inputs are untouched and mirror toolType defaults to the shared ToolEntry port', () => { + const primary = Object.freeze([Object.freeze(tool('primary', { stepRef: 'same', detail: 'original' }))]); + const mirrors: readonly SanitizedToolLogEntry[] = Object.freeze([ + Object.freeze({ icon: '🔧', label: 'mirror', stepRef: 'same', status: 'done', detail: '' }), + Object.freeze({ icon: '🤖', label: 'anonymous' }), + ]); + const result = mergeLatestTools(primary, mirrors, 'run'); + result[0]!.label = 'changed'; + assert.equal(primary[0]?.label, 'primary'); + assert.equal(primary[0]?.detail, 'original'); + assert.equal(mirrors[0]?.label, 'mirror'); + assert.equal(result[1]?.toolType, 'tool'); +}); + +test('a small primary plus capped 161-tool RAM mirrors keeps one head marker and 159 real tools after sanitize', () => { + const mirrors = sanitizeToolLogForDurableStorage(Array.from({ length: 161 }, (_, i) => + tool(`mirror-${i + 1}`, { stepRef: `item-${i + 1}`, status: 'done' }))); + assert.equal(mirrors[0]?.label, '2 tool events omitted'); + const merged = mergeLatestTools([tool('boss', { stepRef: 'boss' })], mirrors, 'run'); + assert.equal(merged[0]?.label, '2 tool events omitted', 'the mirror marker precedes the primary tools'); + assert.equal(merged[1]?.label, 'boss', 'real primary tools still precede real mirror tools'); + const sanitized = sanitizeToolLogForDurableStorage(merged); + assert.equal(sanitized.length, 160); + assert.equal(sanitized[0]?.label, '3 tool events omitted', 'only the final sanitizer accounts for the extra capped tool'); + assert.deepEqual(sanitized.slice(1).map(t => t.label), Array.from({ length: 159 }, (_, i) => `mirror-${i + 3}`)); + assert.deepEqual(sanitizeToolLogForDurableStorage(mergeLatestTools([], mirrors, 'run')), mirrors, 'empty primary retains the RAM fallback marker'); +}); + +test('markers from both sources collapse to the primary marker at the head without summing or tool identity collisions', () => { + const primaryMarker = tool('5 tool events omitted', { icon: '⚠️', stepRef: 'boss', status: 'done' }); + const mirrorMarker = tool('9 tool events omitted', { icon: '⚠️', stepRef: 'worker', status: 'done' }); + const primary = [tool('boss', { stepRef: 'boss' }), primaryMarker, { ...primaryMarker }]; + const mirrors = [tool('worker', { stepRef: 'worker' }), mirrorMarker, { ...mirrorMarker }]; + const merged = mergeLatestTools(primary, mirrors, 'run'); + assert.deepEqual(merged.map(t => t.label), ['5 tool events omitted', 'boss', 'worker']); + assert.deepEqual(sanitizeToolLogForDurableStorage(merged).map(t => t.label), ['5 tool events omitted', 'boss', 'worker']); + assert.equal(primary[0]?.label, 'boss', 'inputs stay in their original order'); + assert.equal(mirrors[0]?.label, 'worker'); + assert.deepEqual(mergeLatestTools([], [mirrorMarker, { ...mirrorMarker }], 'run'), [mirrorMarker]); +}); diff --git a/tests/unit/print-activity-lifecycle.test.ts b/tests/unit/print-activity-lifecycle.test.ts new file mode 100644 index 000000000..f455f9c90 --- /dev/null +++ b/tests/unit/print-activity-lifecycle.test.ts @@ -0,0 +1,124 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { db } from '../../src/core/db.js'; +import { settings } from '../../src/core/config.js'; +import { startTraceRun, getTraceRun } from '../../src/trace/store.js'; +import { readActivityPage } from '../../src/trace/activity-journal.js'; +import { createPrintActivity } from '../../src/agent/runtime/print-activity.js'; +import { extractFromEvent } from '../../src/agent/events/index.js'; +import { handleAgentExit, clearGoalTimers, setSpawnAgent, type ExitHandlerParams } from '../../src/agent/lifecycle-handler.js'; +import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bus.js'; +import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; +import { createSlackForwarder } from '../../src/slack/forwarder.js'; +import { resetGoalStore } from '../../src/goal/store.js'; +import type { SpawnContext } from '../../src/types/agent.js'; + +let serial = 0; +test.beforeEach(() => { + resetGoalStore(); clearGoalTimers(); + settings.memory.enabled = false; settings.fallbackOrder = []; +}); +test.afterEach(() => { clearGoalTimers(); resetGoalStore(); }); + +function fixture() { + const n = ++serial; + const sessionId = `print-chat-${n}`, scope = `print-scope-${n}`; + db.prepare('INSERT INTO chat_sessions(id,seq,label) VALUES(?,?,?)').run(sessionId, 8000 + n, 'print fixture'); + const runId = startTraceRun({ cli: 'codex', sessionId, scopeKey: scope }); + const ctx: SpawnContext = { fullText: '', toolLog: [], traceLog: [], stderrBuf: '', seenToolKeys: new Set(), + hasClaudeStreamEvents: false, sessionId: 'provider-private', cost: null, turns: null, duration: null, tokens: null, + traceRunId: runId, traceAudience: 'public', activityIdentity: { sessionId, scope } }; + ctx.printActivity = createPrintActivity({ runId, sessionId, scope, turnId: runId, audience: 'public' }, 'codex'); + let result: Parameters[0] | undefined; + let resolves = 0, ends = 0, respawns = 0; + setSpawnAgent(() => { respawns++; return { promise: Promise.resolve({ text: 'unexpected retry', code: 0 }) }; }); + const params: ExitHandlerParams = { + ctx, code: 0, cli: 'codex', model: 'fixture', resumeKey: null, agentLabel: 'fixture', mainManaged: true, + origin: 'web', prompt: 'fixture', opts: { _skipSessionPersist: true, _isSmokeContinuation: true }, cfg: {}, + ownerGeneration: 1, persistenceOwner: { global: 0, scope: 0 }, forceNew: false, empSid: null, + isResume: false, wasKilled: false, wasSteer: false, + smokeResult: { isSmoke: false, confidence: 'low', matchedPattern: null, reason: '' }, + effortDefault: '', costLine: '', resolve: value => { resolves++; result = value; }, + activeProcesses: new Map(), scopeKey: scope, chatSessionId: sessionId, childProcess: null, + releaseMainRun: () => false, retryState: { setTimer() {}, setResolve() {}, setOrigin() {}, setIsEmployee() {} }, + fallbackState: new Map(), fallbackMaxRetries: 0, processQueue() {}, + onRuntimeEnd: end => { ends++; ctx.printActivity?.finish(end); }, + }; + return { ctx, params, runId, sessionId, scope, + result: () => result, calls: () => ({ resolves, ends, respawns }), + rows: () => db.prepare("SELECT content,trace_run_id FROM messages WHERE session_id=? AND role='assistant'").all(sessionId) as { content: string; trace_run_id: string }[] }; +} + +for (const fault of ['none', 'append', 'terminal', 'link', 'finalize'] as const) { + test(`accepted print parser → real lifecycle → journal; ${fault} failure never changes one final send`, async t => { + let sends = 0; + t.mock.method(globalThis, 'fetch', async () => { + sends++; + return new Response(JSON.stringify({ ok: true, ts: 'fixture-ts' }), { headers: { 'content-type': 'application/json' } }); + }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + const events: BusEvent[] = [], legacy: Array<{ type: string; data: Record }> = []; + const pending: Promise[] = []; + const forward = createSlackForwarder({ getToken: () => 'fixture-token', + getLastTarget: () => ({ channel: 'slack', targetKind: 'channel', peerKind: 'channel', targetId: 'C-fixture' }) }); + const listener = (type: string, data: Record) => { legacy.push({ type, data }); pending.push(forward(type, data)); }; + addBroadcastListener(listener); const unsubscribe = subscribe(e => events.push(e)); + const f = fixture(); + let trigger = false; + try { + if (fault === 'append' || fault === 'terminal') { + db.exec(`CREATE TRIGGER print_fault BEFORE INSERT ON trace_events + WHEN new.source='runtime' ${fault === 'terminal' ? "AND new.event_type='turn-end'" : ''} + BEGIN SELECT RAISE(ABORT,'print fixture fault'); END`); + trigger = true; + } else if (fault === 'link' || fault === 'finalize') { + db.exec(`CREATE TRIGGER print_fault BEFORE UPDATE OF ${fault === 'link' ? 'message_id' : 'status'} ON trace_runs + BEGIN SELECT RAISE(ABORT,'print fixture fault'); END`); + trigger = true; + } + extractFromEvent('codex', { type: 'item.completed', item: { type: 'agent_message', text: 'prelude', channel: 'commentary' } }, f.ctx, 'fixture'); + extractFromEvent('codex', { type: 'item.completed', item: { type: 'agent_message', text: 'selected answer' } }, f.ctx, 'fixture'); + await assert.doesNotReject(handleAgentExit(f.params)); + await Promise.all(pending); + assert.deepEqual(f.calls(), { resolves: 1, ends: 1, respawns: 0 }); + assert.equal(f.result()?.runtimeOutcome, undefined, 'print never invents native outcome'); + assert.deepEqual(f.rows(), [{ content: 'selected answer', trace_run_id: f.runId }]); + const final = legacy.filter(e => e.type === 'agent_done'); + assert.equal(final.length, 1); assert.equal(final[0]?.data['text'], 'selected answer'); + assert.equal(sends, 1, 'real forwarder called only the stubbed HTTP boundary once'); + assert.equal(legacy.some(e => e.type === 'agent_runtime' || e.type === 'agent_runtime_gap'), false); + const p = readActivityPage({ runId: f.runId, sessionId: f.sessionId, after: 0, limit: 40 })!; + if (fault === 'append' || fault === 'terminal') { + assert.equal(p.incomplete, true); assert.equal(p.events.some(e => e.kind === 'turn-end'), false); + assert.equal(events.filter(e => e.event === 'agent_runtime_gap').length, 1); + } else { + const end = p.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'selected answer'); + assert.ok(p.events.some(e => e.kind === 'message' && e.phase === 'commentary' && e.text === 'prelude')); + const compat = events.findIndex(e => e.event === 'agent_done'); + const canonical = events.findIndex(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-end'); + assert.ok(compat >= 0 && canonical > compat, 'existing compatibility-first ordering preserved'); + } + if (fault === 'link') assert.equal(getTraceRun(f.runId)?.message_id, null); + else assert.ok(getTraceRun(f.runId)?.message_id); + } finally { + if (trigger) db.exec('DROP TRIGGER print_fault'); + unsubscribe(); removeBroadcastListener(listener); + } + }); +} + +test('print tool-only completion links its authoritative empty MESSAGE to journal discovery', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); + const f = fixture(); + extractFromEvent('codex', { type: 'item.completed', item: { id: 'tool-only', type: 'command_execution', + command: 'echo fixture', status: 'completed', exit_code: 0, aggregated_output: '' } }, f.ctx, 'fixture'); + assert.ok(f.ctx.toolLog.length > 0); + await handleAgentExit(f.params); + assert.deepEqual(f.rows(), [{ content: '', trace_run_id: f.runId }]); + assert.ok(getTraceRun(f.runId)?.message_id); + const p = readActivityPage({ runId: f.runId, sessionId: f.sessionId, after: 0, limit: 40 })!; + const end = p.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, ''); + assert.equal(p.incomplete, false); assert.deepEqual(f.calls(), { resolves: 1, ends: 1, respawns: 0 }); +}); diff --git a/tests/unit/trace-message-hydration.test.ts b/tests/unit/trace-message-hydration.test.ts index a1dab2138..4c55942a6 100644 --- a/tests/unit/trace-message-hydration.test.ts +++ b/tests/unit/trace-message-hydration.test.ts @@ -5,6 +5,7 @@ // (lifecycle-handler.ts:524); worker child runs fold in via parent_run_id once Phase 2's // cross-process linkage lands. Audience-filtered so internal worker noise stays hidden. +import '../setup/test-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -12,7 +13,12 @@ import { stampTraceTool, linkTraceRunToMessage, listToolEntriesForMessage, + appendTraceEvent, + getTraceEvent, + updateTraceToolRow, } from '../../src/trace/store.js'; +import { resolveToolLog } from '../../src/routes/messages.js'; +import type { ToolEntry } from '../../src/types/agent.js'; test('P3H-001: boss message tools hydrate from trace_events by message_id, in seq order', () => { const runId = startTraceRun({ cli: 'claude', audience: 'public' }); @@ -43,5 +49,63 @@ test('P3H-003: public hydration excludes internal-audience (worker) runs', () => linkTraceRunToMessage(runId, messageId); assert.deepEqual(listToolEntriesForMessage(messageId, { audience: 'public' }), [], 'internal run excluded from public hydration'); - assert.equal(listToolEntriesForMessage(messageId, { audience: 'internal' }).length, 1, 'internal audience sees it'); + const internal = listToolEntriesForMessage(messageId, { audience: 'internal' }); + assert.equal(internal.length, 1, 'internal audience sees it'); + assert.equal(internal[0]?.traceRunId, runId); + assert.equal(internal[0]?.traceSeq, 1); + assert.equal(internal[0]?.detailAvailable, false); + assert.equal(internal[0]?.rawRetentionStatus, 'internal'); +}); + +test('message hydration synthesizes noncontiguous pointers and current SQL detail metadata', () => { + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + appendTraceEvent({ runId, source: 'cli_raw', eventType: 'text', raw: 'between tools' }); + const tool: ToolEntry = { icon: '🔧', label: 'read', toolType: 'tool', detail: 'initial' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + linkTraceRunToMessage(runId, 990401); + const initial = listToolEntriesForMessage(990401)[0]!; + assert.equal(initial.traceRunId, runId); + assert.equal(initial.traceSeq, 2); + assert.equal(initial.detailAvailable, true); + assert.equal(initial.detailBytes, getTraceEvent(runId, 2)?.bytes); + assert.equal(initial.rawRetentionStatus, 'available'); + updateTraceToolRow({ ...tool, status: 'done', detail: 'a much longer completed result' }); + const updated = listToolEntriesForMessage(990401)[0]!; + assert.equal(updated.status, 'done'); + assert.equal(updated.detail, 'a much longer completed result'); + assert.equal(updated.detailBytes, getTraceEvent(runId, 2)?.bytes, 'SQL bytes replace the stale pointer embedded in raw JSON'); + assert.notEqual(updated.detailBytes, initial.detailBytes); + updateTraceToolRow({ ...tool, status: 'done', detail: 'x'.repeat(100_000) }); + const spilled = listToolEntriesForMessage(990401)[0]!; + assert.equal(spilled.rawRetentionStatus, 'spilled'); + assert.equal(spilled.detailBytes, getTraceEvent(runId, 2)?.bytes); + assert.equal(spilled.detail?.length, 100_000); +}); + +test('resolveToolLog preserves boss-first ordering and cross-run/unscoped blob workers', () => { + const runId = startTraceRun({ cli: 'claude', audience: 'public' }); + const otherRunId = startTraceRun({ cli: 'claude', audience: 'public' }); + const workerRunId = startTraceRun({ cli: 'claude', audience: 'internal' }); + const tool: ToolEntry = { icon: '🔧', label: 'boss', toolType: 'tool', stepRef: 'shared', status: 'running', detail: 'old' }; + stampTraceTool(tool, { traceRunId: runId, traceAudience: 'public' }); + updateTraceToolRow({ ...tool, status: 'done', detail: '' }); + stampTraceTool({ ...tool, label: 'other-boss', traceRunId: undefined, traceSeq: undefined }, { traceRunId: otherRunId, traceAudience: 'public' }); + linkTraceRunToMessage(runId, 990501); + linkTraceRunToMessage(otherRunId, 990501); + const blob = JSON.stringify([ + { ...tool, label: 'blob-boss' }, + { ...tool, traceRunId: workerRunId, label: 'worker-start', isEmployee: true }, + { ...tool, traceRunId: workerRunId, label: 'worker-done', status: 'done', isEmployee: true }, + { ...tool, traceRunId: undefined, label: 'unscoped-worker', isEmployee: true }, + { icon: '🤖', label: 'anonymous-worker', isEmployee: true }, + ]); + const result = JSON.parse(resolveToolLog(990501, blob, true)!) as ToolEntry[]; + assert.deepEqual(result.slice(0, 2).map(t => t.traceRunId).sort(), [runId, otherRunId].sort()); + assert.deepEqual(result.slice(2).map(t => t.label), ['worker-done', 'unscoped-worker', 'anonymous-worker']); + const boss = result.find(t => t.traceRunId === runId)!; + assert.equal(boss.status, 'done'); + assert.equal(boss.detail ?? '', ''); + assert.equal(boss.traceSeq, 1); + assert.equal((JSON.parse(resolveToolLog(990501, blob, false)!) as ToolEntry[])[0]?.label, 'blob-boss'); + assert.equal(resolveToolLog(990599, blob, true), resolveToolLog(990599, blob, false), 'missing rows retain legacy blob fallback'); }); From c3289bdcd862d9f31e4c670e1aef2d48769f1b19 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:03:02 +0900 Subject: [PATCH 18/33] feat: connect print activity across spawn and failure paths --- src/agent/runtime/print-activity.ts | 11 +- src/agent/spawn.ts | 26 ++++- tests/unit/print-activity-projection.test.ts | 23 +++- tests/unit/print-bypass-paths.test.ts | 104 +++++++++++++++++++ tests/unit/print-spawn-journal.test.ts | 98 +++++++++++++++++ 5 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 tests/unit/print-bypass-paths.test.ts create mode 100644 tests/unit/print-spawn-journal.test.ts diff --git a/src/agent/runtime/print-activity.ts b/src/agent/runtime/print-activity.ts index 2d545b05b..7f584264c 100644 --- a/src/agent/runtime/print-activity.ts +++ b/src/agent/runtime/print-activity.ts @@ -1,6 +1,7 @@ import type { RuntimeEventContext } from './events.js'; import { markActivityFailure } from '../../trace/activity-journal.js'; -import { RuntimeProjection } from './projection.js'; +import { finalizeTraceRun } from '../../trace/store.js'; +import { RuntimeProjection, type RuntimeEnd } from './projection.js'; import { createPrintActivityProjection, type PrintActivityProjection } from './print-projection.js'; /** Reuses canonical redaction, preview bounds and the existing per-run gap latch. */ @@ -22,3 +23,11 @@ export function createPrintActivity(context: RuntimeEventContext, provider: stri } }); } + +/** Existing spawn error/retry paths that bypass the normal lifecycle still close their trace. */ +export function finishPrintActivity(context: { printActivity?: PrintActivityProjection; traceRunId?: string }, end: RuntimeEnd): void { + try { context.printActivity?.finish(end); } + catch { console.warn('[activity:print] bypass_observer_failed'); } + try { finalizeTraceRun(context.traceRunId, end.status === 'stopped' ? 'interrupted' : end.status, end.error); } + catch { console.warn('[activity:print] bypass_trace_failed'); } +} diff --git a/src/agent/spawn.ts b/src/agent/spawn.ts index f2a963906..d409b46f1 100644 --- a/src/agent/spawn.ts +++ b/src/agent/spawn.ts @@ -70,6 +70,7 @@ import { RuntimeProjection } from './runtime/projection.js'; import { CodexProjection } from './runtime/codex-projection.js'; import { PiProjection } from './runtime/pi-projection.js'; import { PiRawTrace } from './runtime/pi-raw-trace.js'; +import { createPrintActivity, finishPrintActivity } from './runtime/print-activity.js'; import { isNativeAdapterImplemented, isNativeWorkerImplemented, isSwitchableNativeCli, resolveRuntimeTransport, runtimeSessionBucket } from './runtime/selection.js'; import { asCliEventRecord, discriminate, fieldString, type CliEventRecord } from '../types/cli-events.js'; import type { RemoteTarget } from '../messaging/types.js'; @@ -329,9 +330,10 @@ function broadcastAgentOutput( agentId: agentLabel, cli, text, - ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), ...(textLen !== null ? { textLen } : {}), ...empTag, + ...(ctx.traceRunId ? { traceRunId: ctx.traceRunId } : {}), + ...(ctx.activityIdentity ?? {}), }, audience); } @@ -363,6 +365,7 @@ function emitKiroStreamEvents( if (event.kind === 'assistant_delta') { const segment = normalizeAssistantDisplayText(event.text); if (!segment) continue; + ctx.printActivity?.message(segment, 'append', 'unknown'); if (ctx.liveOutputText !== undefined) { ctx.liveOutputText += segment; } @@ -1725,6 +1728,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { activeProcesses.delete(agentLabel); } broadcast('agent_done', { text: `❌ ${msg}`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public'); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'error', finalText: null, error: msg }); resolve!({ text: '', code: 1 }); if (mainManaged) void processQueue(scopeKey); }); @@ -1749,7 +1753,10 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; + ctx.printActivity = createPrintActivity({ runId: traceRunId, sessionId: chatSessionId, + scope: scopeKey, turnId: traceRunId, audience: traceAudience }, cli); // Flush accumulated 💭 thinking buffer as a single merged event function flushThinking() { @@ -1785,6 +1792,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const parsedTool = parsed.tool; // Buffer 💭 thought chunks → flush when different event arrives if (parsedTool.icon === '💭') { + ctx.printActivity?.reasoning(parsedTool.detail || parsedTool.label, 'append'); ctx.thinkingBuf += parsedTool.detail || parsedTool.label; return; } @@ -1814,10 +1822,12 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // carry no boundary signal and simply accumulate. if (parsed.messageId && ctx.acpAssistantMessageId !== undefined && ctx.acpAssistantMessageId !== parsed.messageId) { + ctx.printActivity?.nextMessage(); ctx.fullText = ''; ctx.outputTextStarted = false; } if (parsed.messageId) ctx.acpAssistantMessageId = parsed.messageId; + ctx.printActivity?.message(parsed.text, 'append', 'unknown'); const segment = appendAssistantTextSegment(ctx, parsed.text); if (segment) { broadcastAgentOutput(ctx, agentLabel, cli, segment, empTag, traceAudience); @@ -1985,6 +1995,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // - trace: if (traceText) traceText = `⏹️ [interrupted]…` handleAgentExit({ ctx, code: acpCode, cli, model, agentLabel, mainManaged, origin, + onRuntimeEnd: end => ctx.printActivity?.finish(end), resumeKey, prompt, opts, cfg, ownerGeneration, persistenceOwner, forceNew, empSid, isResume, wasKilled, wasSteer, smokeResult, @@ -2046,6 +2057,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; const activity = new RuntimeProjection({ runId: traceRunId, sessionId: chatSessionId, scope: scopeKey, @@ -2328,6 +2340,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, }; const activity = new RuntimeProjection({ @@ -2989,6 +3002,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { activeProcesses.delete(agentLabel); } broadcast('agent_done', { text: `❌ ${msg}`, error: true, origin, ...empTag }, isEmployee ? 'internal' : 'public'); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'error', finalText: null, error: msg }); resolve!({ text: '', code: 127 }); if (mainManaged) void processQueue(scopeKey); }); @@ -3043,6 +3057,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { parentLiveScope: parentLiveScopeForChild, traceRunId, traceAudience, + activityIdentity: { sessionId: chatSessionId, scope: scopeKey }, ...(opencodeSpawnAudit ? { opencodeSpawnAudit: opencodeSpawnAudit as Record } : {}), ...(agyResumeOffset > 0 ? { agyResumeOffset, agyBytesReceived: 0 } : {}), ...(cli === 'agy' ? { @@ -3059,6 +3074,8 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { ...(kiroPlainText || cli === 'agy' || cli === 'pi' ? { liveOutputText: '' } : {}), ...(kiroPlainText ? { kiroLastVisibleAt: Date.now(), kiroHeartbeatSent: false } : {}), }; + ctx.printActivity = createPrintActivity({ runId: traceRunId, sessionId: chatSessionId, + scope: scopeKey, turnId: traceRunId, audience: traceAudience }, cli); let agyClosing = false; let agyGuardedStaleDetected = false; const scheduleAgyQuietCompletion = () => { @@ -3204,6 +3221,9 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { } const outputChunk = extractOutputChunk(dispatchCli, event, ctx); if (outputChunk) { + // Dedicated providers observe before their destructive legacy resets. + // Copilot's ordinary print fallback exposes only accepted assistant text here. + if (dispatchCli === 'copilot') ctx.printActivity?.message(outputChunk, 'append', 'unknown'); broadcastAgentOutput(ctx, agentLabel, cli, outputChunk, empTag, (opts.internal || isEmployee) ? 'internal' : 'public'); } }; @@ -3250,6 +3270,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const newText = normalizeAssistantDisplayText(newStart > 0 ? text.slice(newStart) : text); ctx.agyResumeOffset = 0; if (!newText) return; + ctx.printActivity?.message(newText, 'append', 'unknown'); if (ctx.liveOutputText !== undefined) ctx.liveOutputText += newText; ctx.outputTextStarted = true; appendTraceEvent({ runId: ctx.traceRunId, source: 'cli_raw', eventType: 'plain_text', raw: newText }); @@ -3280,6 +3301,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { return; } appendTraceEvent({ runId: ctx.traceRunId, source: 'cli_raw', eventType: 'plain_text', raw: displayText }); + ctx.printActivity?.message(displayFullText, 'replace', 'unknown'); broadcastAgentOutput(ctx, agentLabel, cli, displayText, empTag, traceAudience); scheduleAgyQuietCompletion(); return; @@ -3402,6 +3424,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { if (agyResumeDecision.ok && !opts._agyStaleFreshRetry) { if (mainManaged) releaseMainRun(scopeKey, child, ownerGeneration); else activeProcesses.delete(agentLabel); + finishPrintActivity(ctx, { kind: 'turn-end', status: 'stopped', finalText: null, error: 'AGY stale resume; retrying fresh' }); const { promise: freshPromise } = spawnAgent(prompt, { ...opts, _agyStaleFreshRetry: true, _skipResume: true, _skipInsert: true, }); @@ -3561,6 +3584,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { // - trace: if (traceText) traceText = `⏹️ [interrupted]…` handleAgentExit({ ctx, code: effectiveExitCode, cli, model: runtimeModel, effectiveProvider, agentLabel, mainManaged, origin, + onRuntimeEnd: end => ctx.printActivity?.finish(end), resumeKey, prompt, opts, cfg, ownerGeneration, persistenceOwner, forceNew, empSid, isResume, wasKilled, wasSteer, smokeResult, diff --git a/tests/unit/print-activity-projection.test.ts b/tests/unit/print-activity-projection.test.ts index a31ba80b9..cd29d665a 100644 --- a/tests/unit/print-activity-projection.test.ts +++ b/tests/unit/print-activity-projection.test.ts @@ -2,8 +2,9 @@ import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createPrintActivityProjection } from '../../src/agent/runtime/print-projection.js'; -import { createPrintActivity } from '../../src/agent/runtime/print-activity.js'; -import { startTraceRun, finalizeTraceRun } from '../../src/trace/store.js'; +import { createPrintActivity, finishPrintActivity } from '../../src/agent/runtime/print-activity.js'; +import { startTraceRun, finalizeTraceRun, getTraceRun } from '../../src/trace/store.js'; +import { db } from '../../src/core/db.js'; import { readActivityPage } from '../../src/trace/activity-journal.js'; import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; import { settings } from '../../src/core/config.js'; @@ -83,6 +84,24 @@ test('preview capacity fails visibly through one gap, with no fabricated termina } finally { unsubscribe(); } }); +test('bypass observer failure and trace failure are independently contained', t => { + t.mock.method(console, 'warn', () => {}); + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + let calls = 0; + assert.doesNotThrow(() => finishPrintActivity({ traceRunId: runId, printActivity: { ...observer, + finish: () => { calls++; throw new Error('fixture observer'); }, + } }, { kind: 'turn-end', status: 'error', finalText: null })); + assert.equal(calls, 1); assert.equal(getTraceRun(runId)?.status, 'error', 'trace still finalized after observer throw'); + db.exec("CREATE TRIGGER bypass_trace_failure BEFORE UPDATE OF status ON trace_runs BEGIN SELECT RAISE(ABORT,'fixture'); END"); + try { + assert.doesNotThrow(() => finishPrintActivity({ traceRunId: runId, printActivity: { ...observer, + finish: () => { calls++; }, + } }, { kind: 'turn-end', status: 'error', finalText: null })); + assert.equal(calls, 2, 'observer still called exactly once before trace failure'); + } finally { db.exec('DROP TRIGGER bypass_trace_failure'); } +}); + function context(): SpawnContext { return { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), hasClaudeStreamEvents: false, sessionId: 'native-private', cost: null, turns: null, duration: null, tokens: null, stderrBuf: '', diff --git a/tests/unit/print-bypass-paths.test.ts b/tests/unit/print-bypass-paths.test.ts new file mode 100644 index 000000000..1bd820dba --- /dev/null +++ b/tests/unit/print-bypass-paths.test.ts @@ -0,0 +1,104 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import childProcess from 'node:child_process'; + +const home = process.env['CLI_JAW_HOME']!; +process.env['TMPDIR'] = join(home, 'tmp'); +mkdirSync(process.env['TMPDIR'], { recursive: true }); +mkdirSync(join(home, 'prompts'), { recursive: true }); +mkdirSync(join(home, '.copilot'), { recursive: true }); +writeFileSync(join(home, '.copilot/config.json'), '{"model":"fixture"}'); +const isolatedOs = { ...os, homedir: () => home }; +test.mock.module('node:os', { namedExports: isolatedOs, defaultExport: isolatedOs }); +assert.equal((await import('os')).default.homedir(), home, 'Copilot config writes must be isolated before importing spawn'); + +class ErrorAcp extends EventEmitter { + proc = Object.assign(new EventEmitter(), { pid: undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough() }); + spawn() { queueMicrotask(() => this.emit('error', new Error('fixture ACP failure'))); } + initialize() { return new Promise(() => {}); } + kill() {} +} +test.mock.module('../../src/cli/acp-client.js', { namedExports: { AcpClient: ErrorAcp } }); +let launches = 0; +test.mock.module('node:child_process', { namedExports: { ...childProcess, + spawn: (command: string, args: readonly string[], options: childProcess.SpawnOptions) => { + assert.equal(command, process.execPath, 'no provider process may launch'); + assert.deepEqual(args, []); launches++; + const output = launches === 1 ? 'Warning: conversation "stale" not found\n' : 'Fresh answer\n'; + return childProcess.spawn(command, ['--input-type=module', '-e', + `process.stdin.resume();process.stdin.on('end',()=>process.stdout.write(${JSON.stringify(output)}));`], options); + }, +} }); +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: process.execPath }), +} }); +const argsModule = await import('../../src/agent/args.ts'); +test.mock.module('../../src/agent/args.js', { namedExports: { ...argsModule, buildArgs: () => [], buildResumeArgs: () => [] } }); +const capabilities = await import('../../src/agent/agy-capabilities.ts'); +test.mock.module('../../src/agent/agy-capabilities.js', { namedExports: { ...capabilities, + detectAgyCapabilities: () => ({ ...capabilities.DEFAULT_AGY_CAPABILITIES, usedFallback: false }), +} }); +const resume = await import('../../src/agent/spawn/resume.ts'); +test.mock.module('../../src/agent/spawn/resume.js', { namedExports: { ...resume, + canGuardedAgyResume: (input: { freshBootstrap: boolean }) => ({ ok: !input.freshBootstrap, reason: 'fixture' }), + shouldResumeBucketSession: () => true, +} }); +const watcher = await import('../../src/agent/agy-transcript-watcher.ts'); +test.mock.module('../../src/agent/agy-transcript-watcher.js', { namedExports: { ...watcher, + startAgyTranscriptWatcher: () => ({ stop() {} }), +} }); +const { spawnAgent, activeMainProcesses, activeProcesses } = await import('../../src/agent/spawn.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { db, upsertSessionBucket } = await import('../../src/core/db.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); + +test.beforeEach(t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + config.settings.workingDir = home; config.settings.memory.enabled = false; + config.settings.multiSession.enabled = false; config.settings.activeOverrides = {}; config.settings.fallbackOrder = []; +}); + +test('Copilot ACP error bypass closes the admitted journal and preserves one existing error completion', { timeout: 10_000 }, async () => { + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(e => seen.push(e)); + try { + const result = await spawnAgent('ACP fixture', { cli: 'copilot', model: 'fixture', effort: '', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + assert.equal(result.code, 1); assert.equal(launches, 0); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.status, 'error'); assert.equal(p.events.at(-1)?.kind, 'turn-end'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal(activeMainProcesses.size, 0); assert.equal(activeProcesses.size, 0); + assert.deepEqual(JSON.parse(readFileSync(join(home, '.copilot/config.json'), 'utf8')), { model: 'fixture' }); + } finally { unsubscribe(); } +}); + +test('AGY guarded stale retry closes the old journal before the one existing fresh attempt', { timeout: 15_000 }, async () => { + config.settings.perCli.agy = { model: 'fixture', effort: '', nativeResume: 'guarded' }; + upsertSessionBucket.run('agy', 'stale-conversation', 'fixture', null, 0); + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(e => seen.push(e)); + try { + const result = await spawnAgent('AGY fixture', { cli: 'agy', model: 'fixture', effort: '', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + assert.equal(result.code, 0); assert.equal(launches, 2, 'only the existing stale-resume retry'); + const starts = seen.filter(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start'); + assert.equal(starts.length, 2); + const old = readActivityPage({ runId: String(starts[0]!.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + const fresh = readActivityPage({ runId: String(starts[1]!.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(old.status, 'interrupted'); assert.equal(old.events.at(-1)?.kind, 'turn-end'); + const end = fresh.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'Fresh answer'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE role='assistant'").get() as { n: number }).n, 1); + assert.equal(activeMainProcesses.size, 0); + } finally { unsubscribe(); } +}); diff --git a/tests/unit/print-spawn-journal.test.ts b/tests/unit/print-spawn-journal.test.ts new file mode 100644 index 000000000..91d0373f8 --- /dev/null +++ b/tests/unit/print-spawn-journal.test.ts @@ -0,0 +1,98 @@ +import '../setup/isolated-home.ts'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import childProcess from 'node:child_process'; + +const home = process.env['CLI_JAW_HOME']!; +const script = join(home, 'print-provider.mjs'); +writeFileSync(script, `process.stdin.resume();process.stdin.on('end',()=>{ + const frames=[ + {type:'thread.started',thread_id:'provider-private-id'}, + {type:'item.completed',item:{type:'agent_message',text:'kept commentary',channel:'commentary'}}, + {type:'item.completed',item:{id:'tool-1',type:'command_execution',command:'echo fixture',status:'completed',exit_code:0,aggregated_output:'fixture'}}, + {type:'item.completed',item:{type:'agent_message',text:'print fixture final'}} + ];process.stderr.write('stderr-not-an-assistant-message\\n'); + for(const frame of frames)process.stdout.write(JSON.stringify(frame)+'\\n'); +});`); +let launches = 0; +let launchError = false; +test.mock.module('node:child_process', { namedExports: { ...childProcess, + spawn: (command: string, args: readonly string[], options: childProcess.SpawnOptions) => { + assert.equal(command, process.execPath, 'no provider process may launch'); + assert.deepEqual(args, [script]); launches++; + if (launchError) return childProcess.spawn(join(home, 'missing-fixture-runtime'), [], options); + return childProcess.spawn(command, [...args], options); + }, +} }); +const config = await import('../../src/core/config.ts'); +test.mock.module('../../src/core/config.js', { namedExports: { ...config, + detectCli: () => ({ available: true, path: process.execPath }), +} }); +const argsModule = await import('../../src/agent/args.ts'); +test.mock.module('../../src/agent/args.js', { namedExports: { ...argsModule, + buildArgs: () => [script], buildResumeArgs: () => [script], +} }); +const { spawnAgent, activeMainProcesses, activeProcesses } = await import('../../src/agent/spawn.ts'); +const { subscribe } = await import('../../src/core/event-bus.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); +const { db } = await import('../../src/core/db.ts'); + +test('real print child traverses spawn, accepted parser, lifecycle and durable journal with captured identity', { timeout: 15_000 }, async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + config.settings.workingDir = home; + config.settings.cli = 'codex'; config.settings.memory.enabled = false; + config.settings.multiSession.enabled = false; config.settings.activeOverrides = {}; config.settings.fallbackOrder = []; + config.settings.perCli.codex = { model: 'fixture', effort: '' }; + mkdirSync(join(home, 'prompts'), { recursive: true }); + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(event => seen.push(event)); + try { + const run = spawnAgent('fixture input', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', + scopeKey: 'ignored-scope', chatSessionId: 'ignored-chat', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }); + const result = await run.promise; + assert.equal(result.code, 0); assert.equal(launches, 1); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + assert.ok(start); + const runId = String(start.data['runId']); + const replay = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(replay.incomplete, false); + const end = replay.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'print fixture final'); + assert.ok(replay.events.some(e => e.kind === 'message' && e.phase === 'commentary' && e.text === 'kept commentary')); + assert.ok(!JSON.stringify(replay).includes('provider-private-id')); + assert.ok(!JSON.stringify(replay).includes('stderr-not-an-assistant-message')); + for (const packet of seen.filter(e => e.event === 'agent_output' || e.event === 'agent_tool')) { + assert.equal(packet.data['traceRunId'], runId); + assert.equal(packet.data['sessionId'], 'default'); assert.equal(packet.data['scope'], 'default'); + } + assert.ok(seen.some(e => e.event === 'agent_output')); + assert.ok(seen.some(e => e.event === 'agent_tool')); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal((db.prepare("SELECT COUNT(*) AS n FROM messages WHERE role='assistant' AND trace_run_id=?").get(runId) as { n: number }).n, 1); + assert.equal(activeMainProcesses.size, 0); assert.equal(activeProcesses.size, 0); + } finally { unsubscribe(); } +}); + +test('real asynchronous print spawn failure closes its journal without another attempt', { timeout: 15_000 }, async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); + launchError = true; + const before = launches; + const seen: Array<{ event: string; data: Record }> = []; + const unsubscribe = subscribe(event => seen.push(event)); + try { + const result = await spawnAgent('fixture error', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + assert.equal(result.code, 127); assert.equal(launches, before + 1); + const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; + assert.ok(start); + const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; + assert.equal(p.status, 'error'); + assert.equal(p.events.at(-1)?.kind, 'turn-end'); + assert.equal(seen.filter(e => e.event === 'agent_done').length, 1); + assert.equal(activeMainProcesses.size, 0); + } finally { launchError = false; unsubscribe(); } +}); From 8e76f6bd165f83021db8e2df60935781fb6f8f49 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:04:14 +0900 Subject: [PATCH 19/33] fix: preserve recovered Claude tool identity after read failure --- src/agent/events/claude.ts | 1 + tests/unit/print-provider-observation.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/agent/events/claude.ts b/src/agent/events/claude.ts index e46041349..059078006 100644 --- a/src/agent/events/claude.ts +++ b/src/agent/events/claude.ts @@ -350,6 +350,7 @@ export function handleClaudeEvent( toolType: 'tool', label: 'tool', ...base, + stepRef: `claude:tooluse:${block.tool_use_id}`, icon: block["is_error"] ? '❌' : '✅', status: block["is_error"] ? 'error' : 'done', traceRunId: pointer.traceRunId, diff --git a/tests/unit/print-provider-observation.test.ts b/tests/unit/print-provider-observation.test.ts index 426c72057..d551ec85a 100644 --- a/tests/unit/print-provider-observation.test.ts +++ b/tests/unit/print-provider-observation.test.ts @@ -419,5 +419,7 @@ for (const cli of ['cursor', 'grok', 'opencode', 'claude']) { assert.equal(ctx.fullText, 'Existing legacy answer'); assert.equal(observed.tool.mock.callCount(), 2); assert.equal(observed.tool.mock.calls[1]?.result?.status, 'done'); + assert.equal(observed.tool.mock.calls[1]?.result?.stepRef, observed.tool.mock.calls[0]?.result?.stepRef, + 'unreadable raw storage must not split one observed tool identity'); }); } From 95a79ff87dc2cf41535e766946bb372493319231 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:14:16 +0900 Subject: [PATCH 20/33] test: drive tool log boundaries through lifecycle and HTTP --- tests/unit/live-run-trace-hydration.test.ts | 21 ++++++++++ tests/unit/print-activity-lifecycle.test.ts | 27 ++++++++++++- tests/unit/tool-log-memory-boundaries.test.ts | 39 ++----------------- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/tests/unit/live-run-trace-hydration.test.ts b/tests/unit/live-run-trace-hydration.test.ts index b8cb0f6c3..2099e47a1 100644 --- a/tests/unit/live-run-trace-hydration.test.ts +++ b/tests/unit/live-run-trace-hydration.test.ts @@ -5,6 +5,8 @@ import assert from 'node:assert/strict'; import { createServer, type Server } from 'node:http'; import express, { type NextFunction, type Request, type Response } from 'express'; import { registerOrchestrateRoutes } from '../../src/routes/orchestrate.ts'; +import { registerMessageRoutes } from '../../src/routes/messages.ts'; +import { db } from '../../src/core/db.ts'; import { startTraceRun, stampTraceTool, updateTraceToolRow, countToolTraceRows } from '../../src/trace/store.ts'; import { beginLiveRun, setLiveRunTraceId, appendLiveRunTool, clearLiveRun } from '../../src/agent/live-run-state.ts'; import type { ToolEntry } from '../../src/types/agent.ts'; @@ -169,3 +171,22 @@ test('snapshot recalculates one omission marker for 161 durable tools and preser assert.deepEqual(reconstructed.slice(1).map(t => t.traceSeq), newestSequences, 'all 159 newest real tools survive'); clearLiveRun(SCOPE); }); + +test('actual messages API bounds a legacy tool blob before sending it to clients', async () => { + const raw = JSON.stringify(Array.from({ length: 200 }, (_, i) => ({ icon: 'x', label: `raw-${i}`, + toolType: 'tool', stepRef: `raw-${i}`, detail: 'x'.repeat(600) }))); + const id = Number(db.prepare("INSERT INTO messages(role,content,session_id,tool_log) VALUES('assistant','fixture','default',?)").run(raw).lastInsertRowid); + const app = express(); registerMessageRoutes(app, noAuth); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); assert.ok(address && typeof address === 'object'); + try { + const response = await fetch(`http://127.0.0.1:${address.port}/api/messages?session=default`, { signal: AbortSignal.timeout(3000) }); + assert.equal(response.status, 200); + const body = await response.json() as { data: Array<{ id: number; tool_log: string }> }; + const row = body.data.find(message => message.id === id)!; + assert.ok(row.tool_log.length <= 64_000); assert.notEqual(row.tool_log, raw); + const tools = JSON.parse(row.tool_log) as Array<{ label: string }>; + assert.ok(tools.length <= 160); assert.equal(tools.at(-1)?.label, 'raw-199'); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } +}); diff --git a/tests/unit/print-activity-lifecycle.test.ts b/tests/unit/print-activity-lifecycle.test.ts index f455f9c90..c081a262a 100644 --- a/tests/unit/print-activity-lifecycle.test.ts +++ b/tests/unit/print-activity-lifecycle.test.ts @@ -12,6 +12,7 @@ import { addBroadcastListener, removeBroadcastListener } from '../../src/core/bu import { subscribe, type BusEvent } from '../../src/core/event-bus.js'; import { createSlackForwarder } from '../../src/slack/forwarder.js'; import { resetGoalStore } from '../../src/goal/store.js'; +import { beginLiveRun, appendLiveRunTool, clearLiveRun } from '../../src/agent/live-run-state.js'; import type { SpawnContext } from '../../src/types/agent.js'; let serial = 0; @@ -28,7 +29,7 @@ function fixture() { const runId = startTraceRun({ cli: 'codex', sessionId, scopeKey: scope }); const ctx: SpawnContext = { fullText: '', toolLog: [], traceLog: [], stderrBuf: '', seenToolKeys: new Set(), hasClaudeStreamEvents: false, sessionId: 'provider-private', cost: null, turns: null, duration: null, tokens: null, - traceRunId: runId, traceAudience: 'public', activityIdentity: { sessionId, scope } }; + traceRunId: runId, traceAudience: 'public', liveScope: scope, activityIdentity: { sessionId, scope } }; ctx.printActivity = createPrintActivity({ runId, sessionId, scope, turnId: runId, audience: 'public' }, 'codex'); let result: Parameters[0] | undefined; let resolves = 0, ends = 0, respawns = 0; @@ -122,3 +123,27 @@ test('print tool-only completion links its authoritative empty MESSAGE to journa const end = p.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, ''); assert.equal(p.incomplete, false); assert.deepEqual(f.calls(), { resolves: 1, ends: 1, respawns: 0 }); }); + +test('real lifecycle persists and broadcasts the same bounded boss/worker tool union', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); + t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); + const f = fixture(); f.ctx.fullText = 'final'; + f.ctx.toolLog = Array.from({ length: 200 }, (_, i) => ({ icon: 'x', label: `boss-${i}`, toolType: 'tool', + stepRef: `boss-${i}`, traceRunId: f.runId, detail: 'x'.repeat(2000), status: 'done' })); + beginLiveRun(f.scope, 'codex'); + appendLiveRunTool(f.scope, { icon: 'x', label: 'worker mirror', toolType: 'tool', stepRef: 'worker', + traceRunId: 'tr_worker1234567890', isEmployee: true, detail: 'worker detail' }); + let broadcastTools: unknown; + const listener = (type: string, data: Record) => { if (type === 'agent_done') broadcastTools = data['toolLog']; }; + addBroadcastListener(listener); + try { + await handleAgentExit(f.params); + const row = db.prepare("SELECT tool_log FROM messages WHERE trace_run_id=? AND role='assistant'").get(f.runId) as { tool_log: string }; + const stored = JSON.parse(row.tool_log) as Array<{ stepRef?: string; label: string }>; + assert.equal(stored.length, 160); assert.equal(stored.filter(tool => tool.stepRef).length, 159); + assert.ok(stored.some(tool => tool.label === 'worker mirror')); + assert.ok(stored.some(tool => tool.label.startsWith('boss-'))); + assert.ok(row.tool_log.length <= 64_000); + assert.deepEqual(broadcastTools, stored); + } finally { removeBroadcastListener(listener); clearLiveRun(f.scope); } +}); diff --git a/tests/unit/tool-log-memory-boundaries.test.ts b/tests/unit/tool-log-memory-boundaries.test.ts index d0844fdbb..481273f3a 100644 --- a/tests/unit/tool-log-memory-boundaries.test.ts +++ b/tests/unit/tool-log-memory-boundaries.test.ts @@ -7,42 +7,9 @@ function src(path: string): string { return readFileSync(join(process.cwd(), path), 'utf8'); } -test('backend agent_done DB and broadcast boundaries use sanitized tool logs', () => { - const source = src('src/agent/lifecycle-handler.ts'); - - // Persist unions ctx.toolLog (boss) + liveRun.toolLog (worker mirrors) by stepRef - // since the tool-card hydration fix (devlog 260620 R1) — the old pick-one ternary - // discarded the array that held the worker mirrors. - assert.ok(source.includes('sanitizeToolLogForDurableStorage(unionToolLog)')); - assert.ok(source.includes('for (const t of liveRun.toolLog) pushUnionTool(t)'), 'persist must union ctx + liveRun'); - assert.ok(!source.includes('liveRun.toolLog.length > ctx.toolLog.length ? liveRun.toolLog : ctx.toolLog'), 'pick-one ternary must be gone'); - assert.ok(source.includes('serializeSanitizedToolLog(sanitizedToolLog)')); - // runTag(ctx) rides first since the replay-idempotency patch (260612 audit 08). - // The text expression grew a presentation-only suffix when a watchdog-killed - // turn started saying so (#405), so this matches the ORDER and the sanitized - // tool log rather than the literal text argument. - assert.match( - source, - /broadcast\('agent_done', \{ \.\.\.runTag\(ctx\), text: [^,]+, toolLog: sanitizedToolLog/, - ); -}); - -test('message and orchestrate snapshot API boundaries sanitize before res.json', () => { - // /api/messages lives in routes/messages.ts since the Phase 2 extraction (devlog 260609, 20). - const server = src('src/routes/messages.ts'); - const orchestrate = src('src/routes/orchestrate.ts'); - - // /api/messages routes tool_log through resolveToolLog (Option D, devlog 260620 P3), - // which sanitizes internally: blob → sanitizeSerializedToolLog, trace → serializeSanitizedToolLog. - assert.ok(server.includes('resolveToolLog(row["id"], row["tool_log"]'), 'main read resolves tool_log via Option D boundary'); - assert.ok(server.includes('return sanitizeSerializedToolLog(blobToolLog)'), 'resolveToolLog blob fallback still sanitizes'); - assert.ok(orchestrate.includes('function getSafeLiveRun(scope: string)')); - // WP4 (devlog 260703 doc 12): the RAM log is sanitized on entry, and the - // trace-hydration fallback re-sanitizes the merged rebuild before res.json. - assert.ok(orchestrate.includes('let toolLog = sanitizeToolLogForDurableStorage(liveRun.toolLog)')); - assert.ok(orchestrate.includes('toolLog = sanitizeToolLogForDurableStorage([...boss, ...mirrors])')); - assert.ok(orchestrate.includes('activeRun: getSafeLiveRun(scope)')); -}); +// Backend persistence/broadcast and HTTP boundaries now run behavioral checks in +// print-activity-lifecycle.test.ts and live-run-trace-hydration.test.ts. Source-shape +// assertions here could not verify those boundaries and broke on equivalent refactors. test('frontend history, cache, active-run, and virtual item paths use bounded tool logs', () => { const ui = src('public/js/ui.ts'); From e9b46921633c72564c14a62515eaf7e4ede0ec51 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:39:25 +0900 Subject: [PATCH 21/33] fix: enrich missing metadata on terminal runtime tools --- src/agent/runtime/projection.ts | 10 +++- .../runtime-tool-terminal-enrichment.test.ts | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/unit/runtime-tool-terminal-enrichment.test.ts diff --git a/src/agent/runtime/projection.ts b/src/agent/runtime/projection.ts index a11765292..71aff0c63 100644 --- a/src/agent/runtime/projection.ts +++ b/src/agent/runtime/projection.ts @@ -165,7 +165,15 @@ export class RuntimeProjection { const key = this.key('tool', nativeRef); const previous = this.items.get(key); const old = previous?.kind === 'tool' ? previous : undefined; - if (old && old.status !== 'running') return; + if (old && old.status !== 'running') { + // A result may precede its start. Fill only unknown metadata; terminal + // status, output, detail and established fields remain authoritative. + const name = (!old.name || old.name === 'tool') && patch.name ? patch.name : old.name; + const input = old.input === undefined && patch.input !== undefined + ? this.source(key, 'input', patch.input, false, patch.inputStructured) : old.input; + this.save(key, { ...old, name, ...(input === undefined ? {} : { input }) }); + return; + } const itemId = this.id(key); if (!itemId) return; const body: Tool = { kind: 'tool', itemId, name: old?.name && old.name !== 'tool' ? old.name : patch.name || 'tool', diff --git a/tests/unit/runtime-tool-terminal-enrichment.test.ts b/tests/unit/runtime-tool-terminal-enrichment.test.ts new file mode 100644 index 000000000..6817bf2c9 --- /dev/null +++ b/tests/unit/runtime-tool-terminal-enrichment.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; + +function fixture() { + const events: RuntimeEvent[] = []; + const projection = new RuntimeProjection({ runId: 'r', sessionId: 's', scope: 'scope', turnId: 't', audience: 'internal' }, + (context, body) => { const event = { ...context, version: 1 as const, seq: events.length * 3 + 2, ...body }; events.push(event); return event; }, () => {}); + projection.start('claude'); + return { projection, events, tool: () => events.filter(e => e.kind === 'tool').at(-1)! }; +} +test('late start enriches missing terminal metadata without reopening or changing result', () => { + const f = fixture(); + f.projection.tool('id', { status: 'error', output: 'result', detail: 'failure' }); + const id = f.tool().itemId; + f.projection.tool('id', { name: 'Read', input: '{"path":"a"}', inputStructured: true, status: 'running', output: 'wrong', detail: 'wrong' }); + assert.equal(f.tool().name, 'Read'); assert.equal(f.tool().input, '{"path":"a"}'); + assert.equal(f.tool().itemId, id); assert.equal(f.tool().status, 'error'); + assert.equal(f.tool().output, 'result'); assert.equal(f.tool().detail, 'failure'); + const count = f.events.length; + f.projection.tool('id', { name: 'Overwrite', input: 'overwrite', status: 'done', output: 'overwrite' }); + assert.equal(f.events.length, count); +}); +test('late input uses existing structured redaction and malformed content withholding', () => { + const f = fixture(); + f.projection.tool('a', { status: 'done' }); + f.projection.tool('a', { name: 'tool', input: '{"api_key":"secret-value","path":"safe"}', inputStructured: true }); + assert.ok(!JSON.stringify(f.events).includes('secret-value')); + f.projection.tool('b', { status: 'stopped' }); + f.projection.tool('b', { input: '{"token":"secret-fragment', inputStructured: true }); + assert.ok(!JSON.stringify(f.events).includes('secret-fragment')); + assert.equal(f.tool().status, 'stopped'); +}); +test('terminal enrichment respects preview budgets and closed projection boundary', () => { + const f = fixture(); + f.projection.tool('id', { status: 'done', output: 'ok' }); + f.projection.tool('id', { name: 'N'.repeat(200), input: 'x'.repeat(100_000) }); + assert.ok(f.tool().name.length <= 120); assert.ok((f.tool().input?.length ?? 0) <= 3000); + assert.ok(f.projection.diagnostics().withinSnapshotCap); + f.projection.close({ kind: 'turn-end', status: 'done', finalText: null }); + const count = f.events.length; f.projection.tool('id', { name: 'late' }); + assert.equal(f.events.length, count); +}); +test('known terminal metadata and ordinary start-complete sequence remain immutable', () => { + const f = fixture(); + f.projection.tool('id', { name: 'command', input: 'pwd', status: 'running' }); + f.projection.tool('id', { status: 'done', output: '/tmp' }); + const count = f.events.length; + f.projection.tool('id', { name: 'other', input: 'rm', status: 'running', output: 'overwrite' }); + assert.equal(f.events.length, count); assert.equal(f.tool().input, 'pwd'); assert.equal(f.tool().output, '/tmp'); +}); From 667573dd9b24faf9f3ac6c5c8a8890c9af6f1767 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:41:26 +0900 Subject: [PATCH 22/33] fix: preserve terminal tool results under preview budget pressure --- src/agent/runtime/projection.ts | 7 +++++-- tests/unit/runtime-tool-terminal-enrichment.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/agent/runtime/projection.ts b/src/agent/runtime/projection.ts index 71aff0c63..a0f475636 100644 --- a/src/agent/runtime/projection.ts +++ b/src/agent/runtime/projection.ts @@ -126,7 +126,7 @@ export class RuntimeProjection { catch { this.report('persistence'); return WITHHELD_PREVIEW; } } - private save(key: string, body: Preview): void { + private save(key: string, body: Preview, preserveTerminal = false): void { if (this.ended || this.recordingFailed) return; const previous = this.items.get(key); const previousJson = previous ? JSON.stringify(previous) : ''; @@ -144,6 +144,9 @@ export class RuntimeProjection { } } let json = JSON.stringify(next); + // Enrichment is optional. Never spend the budget by deleting already + // authoritative terminal fields to make room for newly learned metadata. + if (preserveTerminal && json.length > available) { this.report('capacity'); return; } // Charge JSON escaping too. Keep already retained IDs; no eviction/reopen. for (const field of fields.filter(field => field !== 'name').reverse()) { if (json.length <= available) break; @@ -171,7 +174,7 @@ export class RuntimeProjection { const name = (!old.name || old.name === 'tool') && patch.name ? patch.name : old.name; const input = old.input === undefined && patch.input !== undefined ? this.source(key, 'input', patch.input, false, patch.inputStructured) : old.input; - this.save(key, { ...old, name, ...(input === undefined ? {} : { input }) }); + this.save(key, { ...old, name, ...(input === undefined ? {} : { input }) }, true); return; } const itemId = this.id(key); diff --git a/tests/unit/runtime-tool-terminal-enrichment.test.ts b/tests/unit/runtime-tool-terminal-enrichment.test.ts index 6817bf2c9..d6e93299b 100644 --- a/tests/unit/runtime-tool-terminal-enrichment.test.ts +++ b/tests/unit/runtime-tool-terminal-enrichment.test.ts @@ -50,3 +50,13 @@ test('known terminal metadata and ordinary start-complete sequence remain immuta f.projection.tool('id', { name: 'other', input: 'rm', status: 'running', output: 'overwrite' }); assert.equal(f.events.length, count); assert.equal(f.tool().input, 'pwd'); assert.equal(f.tool().output, '/tmp'); }); +test('saturated aggregate budget rejects enrichment instead of erasing terminal output', () => { + const f = fixture(); + f.projection.tool('target', { status: 'done', output: 'R'.repeat(1000), detail: 'D'.repeat(1000) }); + const original = f.tool(); + for (let i = 0; i < 7; i++) f.projection.tool(`fill-${i}`, { status: 'done', output: 'F'.repeat(3000) }); + assert.ok(f.projection.diagnostics().previewChars > 21_000); + f.projection.tool('target', { name: 'Read', input: 'I'.repeat(3000), status: 'running' }); + const target = f.events.filter(e => e.kind === 'tool' && e.itemId === original.itemId).at(-1)!; + assert.equal(target.output, original.output); assert.equal(target.detail, original.detail); assert.equal(target.status, 'done'); +}); From 3bd12016f3c82a3fcbfbbfce4408fd9f5c8b4e68 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 02:58:23 +0900 Subject: [PATCH 23/33] feat: expose published runtime item identity lookup --- src/agent/runtime/projection.ts | 6 ++++ tests/unit/runtime-projection-lookup.test.ts | 36 ++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/unit/runtime-projection-lookup.test.ts diff --git a/src/agent/runtime/projection.ts b/src/agent/runtime/projection.ts index a0f475636..c74cb3128 100644 --- a/src/agent/runtime/projection.ts +++ b/src/agent/runtime/projection.ts @@ -78,6 +78,12 @@ export class RuntimeProjection { return createHash('sha256').update(JSON.stringify([kind, nativeRef])).digest('hex'); } + /** Published display linkage only; never allocation or approval authority. */ + itemId(kind: Preview['kind'], nativeRef: string): string | null { + if (this.recordingFailed) return null; + return this.items.get(this.key(kind, nativeRef))?.itemId ?? null; + } + private id(key: string): string | null { const found = this.items.get(key); if (found) return found.itemId; diff --git a/tests/unit/runtime-projection-lookup.test.ts b/tests/unit/runtime-projection-lookup.test.ts new file mode 100644 index 000000000..2296682e9 --- /dev/null +++ b/tests/unit/runtime-projection-lookup.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; +import type { RuntimeEvent } from '../../src/shared/runtime-contract.ts'; +function fixture() { + const events: RuntimeEvent[] = []; let failing = false; + const projection = new RuntimeProjection({ runId: 'run', sessionId: 'chat', scope: 's', turnId: 't', audience: 'internal' }, + (ctx, body) => { if (failing) return null; const event = { ...ctx, version: 1 as const, seq: events.length + 1, ...body }; events.push(event); return event; }, () => {}); + return { projection, events, fail: () => { failing = true; } }; +} +test('lookup never allocates or emits and returns actual published terminal tool identity', () => { + const f = fixture(); + assert.equal(f.projection.itemId('tool', 'native-private'), null); + assert.equal(f.projection.diagnostics().items, 0); assert.equal(f.events.length, 0); + f.projection.tool('native-private', { name: 'Read', status: 'done', output: 'ok' }); + const event = f.events[0]!; assert.equal(event.kind, 'tool'); + assert.equal(f.projection.itemId('tool', 'native-private'), event.itemId); + assert.equal(event.itemId, 'item-1'); assert.equal(f.events.length, 1); + assert.ok(!JSON.stringify(f.events).includes('native-private')); + assert.equal(f.projection.itemId('message', 'native-private'), null); +}); +test('recording failure makes previous display mapping unavailable without new events', () => { + const f = fixture(); f.projection.tool('a', { status: 'running' }); + assert.ok(f.projection.itemId('tool', 'a')); + f.fail(); f.projection.text('message', 'b', 'lost', 'replace'); + const count = f.events.length; + assert.equal(f.projection.itemId('tool', 'a'), null); assert.equal(f.projection.itemId('message', 'b'), null); + assert.equal(f.events.length, count); +}); +test('capacity-rejected item never gains a fabricated lookup identity', () => { + const f = fixture(); + for (let i = 0; i < 160; i++) f.projection.tool(`id-${i}`, { status: 'done' }); + const count = f.events.length; + f.projection.tool('overflow', { status: 'running' }); + assert.equal(f.projection.itemId('tool', 'overflow'), null); assert.equal(f.events.length, count); +}); From 4ed5f33c820fe3b43b9d7220d32bb478cc3a11f1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:23:23 +0900 Subject: [PATCH 24/33] fix: allow explicit print terminal tool refreshes --- src/agent/runtime/print-activity.ts | 2 +- src/agent/runtime/projection.ts | 5 ++-- tests/unit/print-activity-projection.test.ts | 30 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/agent/runtime/print-activity.ts b/src/agent/runtime/print-activity.ts index 7f584264c..87bbeea48 100644 --- a/src/agent/runtime/print-activity.ts +++ b/src/agent/runtime/print-activity.ts @@ -18,7 +18,7 @@ export function createPrintActivity(context: RuntimeEventContext, provider: stri case 'message': projection.text('message', body.itemId, body.text, body.operation, body.phase); break; case 'reasoning': projection.text('reasoning', body.itemId, body.text, body.operation); break; case 'tool': projection.tool(body.itemId, { name: body.name, status: body.status, - ...(body.detail === undefined ? {} : { detail: body.detail }) }); break; + ...(body.detail === undefined ? {} : { detail: body.detail }) }, { allowTerminalUpdates: true }); break; case 'turn-end': projection.close(body); break; } }); diff --git a/src/agent/runtime/projection.ts b/src/agent/runtime/projection.ts index c74cb3128..c78f13786 100644 --- a/src/agent/runtime/projection.ts +++ b/src/agent/runtime/projection.ts @@ -169,12 +169,13 @@ export class RuntimeProjection { this.emit(next); } - tool(nativeRef: string, patch: ToolPatch): void { + tool(nativeRef: string, patch: ToolPatch, options: { allowTerminalUpdates?: boolean } = {}): void { if (this.ended || this.recordingFailed) return; const key = this.key('tool', nativeRef); const previous = this.items.get(key); const old = previous?.kind === 'tool' ? previous : undefined; - if (old && old.status !== 'running') { + const replaceTerminal = options.allowTerminalUpdates === true && patch.status !== undefined && patch.status !== 'running'; + if (old && old.status !== 'running' && !replaceTerminal) { // A result may precede its start. Fill only unknown metadata; terminal // status, output, detail and established fields remain authoritative. const name = (!old.name || old.name === 'tool') && patch.name ? patch.name : old.name; diff --git a/tests/unit/print-activity-projection.test.ts b/tests/unit/print-activity-projection.test.ts index cd29d665a..5ad055ed8 100644 --- a/tests/unit/print-activity-projection.test.ts +++ b/tests/unit/print-activity-projection.test.ts @@ -102,6 +102,36 @@ test('bypass observer failure and trace failure are independently contained', t } finally { db.exec('DROP TRIGGER bypass_trace_failure'); } }); +test('factory preserves a newer terminal tool detail on the same canonical item', () => { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + const tool = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'stable' }; + observer.tool({ ...tool, status: 'running', detail: 'start' }); + observer.tool({ ...tool, status: 'done', detail: 'first result' }); + observer.tool({ ...tool, status: 'done', detail: 'updated result' }); + observer.tool({ ...tool, status: 'running', detail: 'stale start' }); + const tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 3); + assert.equal(new Set(tools.map(e => e.itemId)).size, 1); + assert.equal(tools.at(-1)?.status, 'done'); assert.equal(tools.at(-1)?.detail, 'updated result'); +}); + +test('print opt-in follows explicit terminal status updates without inferring recovery from a running update', () => { + const runId = startTraceRun({ cli: 'print', sessionId: 'default', scopeKey: 'default' }); + const observer = createPrintActivity({ runId, sessionId: 'default', scope: 'default', turnId: runId, audience: 'public' }, 'print'); + const tool = { icon: 'x', label: 'command', toolType: 'tool', stepRef: 'severity' }; + observer.tool({ ...tool, status: 'error', detail: 'failed' }); + observer.tool({ ...tool, status: 'running', detail: 'stale' }); + let tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 1); assert.equal(tools[0]?.status, 'error'); assert.equal(tools[0]?.detail, 'failed'); + // Legacy print providers explicitly revise completed snapshots. Native callers + // retain their existing frozen error/result policy unless they opt in. + observer.tool({ ...tool, status: 'done', detail: '' }); + tools = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!.events.filter(e => e.kind === 'tool'); + assert.equal(tools.length, 2); assert.equal(tools[1]?.status, 'done'); assert.equal(tools[1]?.detail, ''); + assert.equal(tools[0]?.itemId, tools[1]?.itemId); +}); + function context(): SpawnContext { return { fullText: '', traceLog: [], toolLog: [], seenToolKeys: new Set(), hasClaudeStreamEvents: false, sessionId: 'native-private', cost: null, turns: null, duration: null, tokens: null, stderrBuf: '', From 4a6ad709f3498ed38454ea862304e59c28f91903 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:23:23 +0900 Subject: [PATCH 25/33] test: isolate shared projection regression fixtures --- tests/unit/runtime-projection-lookup.test.ts | 1 + tests/unit/runtime-tool-terminal-enrichment.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/unit/runtime-projection-lookup.test.ts b/tests/unit/runtime-projection-lookup.test.ts index 2296682e9..3129872d9 100644 --- a/tests/unit/runtime-projection-lookup.test.ts +++ b/tests/unit/runtime-projection-lookup.test.ts @@ -1,3 +1,4 @@ +import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; diff --git a/tests/unit/runtime-tool-terminal-enrichment.test.ts b/tests/unit/runtime-tool-terminal-enrichment.test.ts index d6e93299b..58a557524 100644 --- a/tests/unit/runtime-tool-terminal-enrichment.test.ts +++ b/tests/unit/runtime-tool-terminal-enrichment.test.ts @@ -1,3 +1,4 @@ +import '../setup/isolated-home.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { RuntimeProjection } from '../../src/agent/runtime/projection.ts'; From a22fd8fd00825f3ef0fd9b7d329bf95cf8de1035 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:28:20 +0900 Subject: [PATCH 26/33] docs: describe print Activity and final delivery boundaries --- AGENTS.md | 4 +++- CLAUDE.md | 2 ++ README.md | 6 ++++-- structure/AGENTS.md | 2 ++ structure/agent_spawn.md | 7 +++++++ structure/prompt_flow.md | 6 ++++++ structure/runtime-integration.md | 31 +++++++++++++++++++++++++++++++ structure/str_func.md | 29 ++++++++++++++++------------- structure/stream-events.md | 6 ++++++ 9 files changed, 77 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12746a8e2..94c125a7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Architecture Docs Sync -- Native event foundation: `src/shared/runtime-contract.ts` + `src/agent/runtime/*` own canonical Codex/Pi projections and optional explicit outcomes. `agent_runtime`/`agent_runtime_gap` publish directly to SSE, bypassing messaging listeners. Native compatibility terminals carry only finality/status and existing trace identity; no public partial/outcome object. Preserve legacy final selection when outcome is absent, and interrupted MESSAGE salvage before exit settlement. See `structure/runtime-integration.md` and `structure/stream-events.md`; Activity layout/default settings are separate follow-on work. +- Native event foundation: `src/shared/runtime-contract.ts` + `src/agent/runtime/*` own canonical Codex/Pi projections and optional explicit outcomes. `agent_runtime`/`agent_runtime_gap` publish directly to SSE, bypassing messaging listeners. Native compatibility terminals carry only finality/status and existing trace identity; no public partial/outcome object. Preserve legacy final selection when outcome is absent, and interrupted MESSAGE salvage before exit settlement. See `structure/runtime-integration.md` and `structure/stream-events.md`; display defaults and durable history use `shared/presentation.ts` and `trace/activity-journal.ts`, with client layout handled separately. - Runtime selection: only Cursor/Grok/Claude accept `perCli..transport`; existing absence stays print before defaults merge. Native switchable keys prefix the whole legacy bucket with `native-v1:` and never overwrite the print singleton. Capture transport/bucket once, forward through lifecycle persistence/compact, and keep scoped resets exact. Unsupported main/worker native adapters fail before print/fallback work; compiled support in `/api/cli-status` is separate from cached auth/binary readiness. Codex App/Pi keys remain unchanged. - `structure/` is the current architecture-doc hub; do not point new docs at `devlog/structure/`. @@ -193,6 +193,8 @@ git add devlog && git commit -m "chore: update devlog ref" && git push ### Native decisions +Print Activity observes accepted legacy parser data and closes from the existing lifecycle-selected application-final. Copilot/ordinary print own observers; native Pi/Codex do not create a second one. Captured `activityIdentity` supplies existing sessionId/scope wire fields even with multi-session disabled. Tool merges use run+ref/seq identity, terminal non-regression and one omission marker; safe trace-tool recovery must not throw into provider parsing. Spawn error/stale-retry bypasses close the observer and trace independently while preserving existing send/resolve/retry order. + Activity journal and raw trace routes share exact chat ownership for every owned row, including historical backfills. Forked message pointers do not grant access. Runtime rows are immutable and bounded; whole-prefix retention reports loss, protects active owners and cannot be disabled by one corrupt control. Journal failure must not interrupt final delivery or MESSAGE salvage. See `structure/runtime-integration.md` and `structure/server_api.md`. Activity display is selected by `presentation.mode` (`activity` default, explicit `legacy` retained), separately from provider transport. Snapshot `GET /api/orchestrate/snapshot?session=...` supplies captured `activityIdentity={sessionId,scope}`; clients validate it before semantic admission. Presentation-only settings writes must not reset fallback state or synchronize execution configuration. Existing instance auth and disabled multi-session resolver policy remain. diff --git a/CLAUDE.md b/CLAUDE.md index 7edaf2a67..5c094b9da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ This repository is a Node.js ESM orchestration runtime for boss/employee dispatc ## Documentation Map +- Print Activity is an observation path: `runtime/print-projection.ts` and `print-activity.ts` reuse canonical projection bounds and gap handling. Legacy accepted-event hooks run before message resets; selected application-final comes only from lifecycle. `merge-tool-log.ts` keys by run plus stable ref/seq and preserves terminal status, identityless tools and one omission marker. Trace-only failures cannot roll back a MESSAGE or introduce another send. + - Activity journal reuses immutable runtime trace rows and nullable admission owners. Replay and raw owned trace reads require exact chat ownership; forks do not inherit access. Whole-prefix retention preserves explicit loss while active owners remain protected. Journal failure cannot gate final delivery or interrupted MESSAGE salvage. - Activity uses `presentation.mode` (`activity` by default, reversible `legacy`) independently of provider transport. `GET /api/orchestrate/snapshot?session=...` returns server-owned `activityIdentity`; validate it before semantic admission. Display-only writes preserve runtime selection and delivery. See `structure/runtime-integration.md`. diff --git a/README.md b/README.md index bc6fe2a25..8dbc58e2b 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,14 @@ ## Install -Conversation display uses `presentation.mode`: `activity` by default, or `legacy` for -the previous transcript view. This preference is independent of provider transport. +Activity-capable clients use `presentation.mode`: `activity` by default, or `legacy` +for the previous transcript view. This preference is independent of provider transport. Activity clients obtain their chat identity from the server snapshot before subscribing to semantic updates; see [runtime integration](structure/runtime-integration.md). Activity history is retained in the bounded trace journal and replayed with a fixed cursor. Owned raw trace requests also carry their captured chat session. +Print runtimes project accepted messages and tool updates into the same journal; +the existing lifecycle still selects the final answer, including when Activity fails.
Safe install — for existing users who want minimal changes diff --git a/structure/AGENTS.md b/structure/AGENTS.md index fe843bf89..bee420d07 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -2,6 +2,8 @@ # structure/ — Sync Guide +- Print observation and tool convergence changes sync `runtime-integration.md`, `agent_spawn.md`, `prompt_flow.md` and `stream-events.md`. The lifecycle remains the final selector; native outcome/salvage and channel ACK/queue owners are unchanged. Never let raw trace failures interrupt accepted provider output. + - Journal changes synchronize nullable trace ownership/backfill, strict replay/raw reads, whole-prefix retention and caller session capture. Keep immutable runtime rows distinct from mutable tool/control rows; finalization uses the DB-only control leaf. Source ownership and limits are documented in `runtime-integration.md`. - Activity identity and display settings: `shared/presentation.ts`, config/settings-merge, runtime-settings and orchestrate snapshot share the contract in `runtime-integration.md` and `server_api.md`. Mode is independent of transport; snapshot identity is server-owned, including when multi-session is disabled. diff --git a/structure/agent_spawn.md b/structure/agent_spawn.md index 1bfb0eb1a..e5fd9513f 100644 --- a/structure/agent_spawn.md +++ b/structure/agent_spawn.md @@ -8,6 +8,13 @@ aliases: [CLI-JAW Agent Spawn, agent runtime, ACP orchestration] # Agent Spawn — agent/ · orchestrator/ · cli/acp-client · goal/ +Activity captures the durable chat/scope at trace admission. Native Pi/Codex retain their +own projections; Copilot and ordinary print create one print observer. Accepted parser +text precedes legacy resets and the existing lifecycle callback supplies application-final. +Error and AGY stale-retry paths that bypass lifecycle close observer/trace independently, +without another message or retry. `agent_output` and `agent_tool` stamp captured identity +after incidental payload fields, including when multi-session is disabled. + > CLI spawn + ACP 분기 + Pi RPC + 스트림 + 큐 + 메모리 flush + PABCD 오케스트레이션 + goal-mode autonomy > 현재 기준: `src/agent/` 46개 TS 파일, `src/orchestrator/` 15개 파일 (+`attestation.ts`), `src/goal/` 5개 파일 (+`pause-gate.ts`), `src/cli/acp-client.ts` diff --git a/structure/prompt_flow.md b/structure/prompt_flow.md index 7b1ca92a2..8abaaa223 100644 --- a/structure/prompt_flow.md +++ b/structure/prompt_flow.md @@ -8,6 +8,12 @@ aliases: [Prompt Injection Flow, CLI-JAW prompt flow, prompt pipeline] # 프롬프트 삽입 흐름 — Prompt Injection Flow +Activity is an observation sink, not a prompt or delivery source. Print parsers retain +accepted intermediate text before legacy resets, while final text still comes from the +existing lifecycle decision. Journal/link failures do not roll back an already-written +MESSAGE, re-enter the handler, or introduce a new inference/send. Semantic SSE bypasses +collectors and messaging listeners; request replay remains non-actionable. + > cli-jaw의 프롬프트 조립 + 주입 전체 흐름. 현재 기준 소스는 `src/prompt/builder.ts` 1040L, `src/memory/injection.ts`, `src/agent/spawn.ts` 2011L, `src/prompt/templates/*` (a1-system 388L, a2-default 25L, orchestration 120L, employee 73L, control-system 56L, worker-context 11L, skills 24L, heartbeat-jobs 4L, heartbeat-default 4L, vision-click 3L). --- diff --git a/structure/runtime-integration.md b/structure/runtime-integration.md index a590d3bd9..c99502a5c 100644 --- a/structure/runtime-integration.md +++ b/structure/runtime-integration.md @@ -44,6 +44,37 @@ Closed metadata may be evicted entirely under row pressure. Raw spill cleanup re symlink roots and child links. Missing journals never justify retrying inference or sending another answer. Replay request views are historical and non-actionable. +Legacy print observation uses `print-projection.ts` (scalar identities only) and the +server factory `print-activity.ts`. The factory reuses RuntimeProjection redaction, +preview bounds and one failure-gap latch. Capacity/truncation is explicit loss rather +than a silently complete journal. Dedicated parsers observe accepted text/reasoning +before resets; plain Claude incremental blocks append, complete streamed snapshots +replace. Copilot ACP replay remains muted; generic hooks observe accepted display text +only. Existing final selection is unchanged and its callback supplies application-final. +Compatibility completion can precede the canonical terminal; clients must deduplicate +both orders and preserve compatibility when the canonical terminal is absent. + +The captured ctx ActivityIdentity never reads provider sessionId. Existing legacy tool +and output packets carry its sessionId/scope plus traceRunId after payload overrides, +including when multi-session is disabled. Internal audience remains private. Normal +print and Copilot create one observer; native Pi/Codex retain their existing projection. +Spawn errors and AGY's existing stale retry close the failed attempt independently of +the ordinary lifecycle. Observer and trace finalization failures are caught separately. + +`mergeLatestTools` preserves primary order, latest status/detail and explicit empty +detail; ref and numeric trace-seq keys are distinct domains within a run. Unknown worker +owners and identityless entries are never guessed. A terminal tool cannot regress to a +late running update. One authoritative omission marker stays at the head. Snapshot +hydration reads bounded durable rows even at equal counts and keeps missing RAM fallback; +history hydration synthesizes existing trace pointers. `getTraceToolEntry` safely reuses +the trace decoder when a tool leaves RAM, preserving its stable ref even on read failure. + +Print alone opts into explicit terminal-to-terminal tool refreshes. The latest explicit +legacy terminal status/detail wins; a running or missing status never reopens a terminal. +Native defaults retain terminal error/output/detail ownership, missing-metadata enrichment, +budget preservation and published-item lookup. No new event schema or display setting +controls this internal distinction. + `src/shared/runtime-contract.ts` defines native/print capabilities, distinct native-input/cancel-reprompt/queued/restart controls, and versioned presentation events. A jaw chat session and routing scope are separate from private provider session IDs. `RuntimeTurnOutcome` keeps authoritative `finalText` (null means absent; an empty string is intentional) separate from partial text. `src/agent/runtime/events.ts` records a validated, redacted body through the existing trace writer before publishing `agent_runtime` on the agent event topic. The trace writer owns sequence allocation; sequence gaps are valid. The tuple codec in `src/trace/runtime-body-codec.ts` preserves numeric usage without weakening raw-trace secret masking. Known structured fragments must be sanitized before clipping by their producer. Recording failure returns null, never a fabricated event or another inference. diff --git a/structure/str_func.md b/structure/str_func.md index e875e9ee9..bb8139586 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -71,6 +71,7 @@ cli-jaw/ │ │ ├── settings-merge.ts ← perCli/activeOverrides/pi deep merge (258L) │ │ └── skill-cache.ts ← 활성 스킬 슬래시 커맨드 캐시 (registerSkillLoader, getSkillCommandsCache, invalidateSkillCommandsCache) (44L) │ ├── agent/ ← CLI 에이전트 런타임 (32 root files + events/ 12 files + spawn/ 3 files) +│ │ ├── merge-tool-log.ts ← run-bound latest tool state and omission marker merge (78L) │ │ ├── runtime/ ← shared native contract foundation (provider activation follows separately) │ │ │ ├── acp/ ← shared v1 wire boundary and bounded native transport │ │ │ │ ├── session.ts ← protocol session, prompt/cancel fences and drain (276L) @@ -84,25 +85,27 @@ cli-jaw/ │ │ │ ├── requests.ts ← ephemeral exact-bound decision registry and safe-view admission (158L) │ │ │ ├── pi-projection.ts ← Pi raw tool snapshots and accepted text/reasoning projection (97L) │ │ │ ├── pi-raw-trace.ts ← bounded delta-only raw retention with explicit control summaries (81L) -│ │ │ ├── projection.ts ← bounded redaction-before-clip snapshots and per-run failure latch (231L) +│ │ │ ├── projection.ts ← bounded redaction-before-clip snapshots and per-run failure latch (249L) +│ │ │ ├── print-projection.ts ← accepted print observation with scalar identities (47L) +│ │ │ ├── print-activity.ts ← canonical print factory and isolated bypass closure (33L) │ │ │ ├── codex-projection.ts ← owned Codex notification mapping (98L) │ │ │ ├── outcome.ts ← non-journal native result handoff and stop precedence (31L) │ │ │ ├── session.ts ← native session/turn/control port (23L) │ │ │ └── events.ts ← validated trace-first semantic emitter (43L) -│ │ ├── spawn.ts ← CLI spawn + ACP/Codex App/Pi RPC/AGY/Kiro plain text/log session capture/claude-e helper 분기 + v2 SQLite session resume + 큐 + 메모리 flush + 429 retry timer + isAgentBusy/isSteerInProgress + buildHistoryBlock compact cutoff + working_dir scoping + enqueue→processQueue race fix + QueueItem persistent DB queue + makeCleanEnv PATH augment (3593L) +│ │ ├── spawn.ts ← CLI spawn + ACP/Codex App/Pi RPC/AGY/Kiro plain text/log session capture/claude-e helper 분기 + v2 SQLite session resume + 큐 + 메모리 flush + 429 retry timer + isAgentBusy/isSteerInProgress + buildHistoryBlock compact cutoff + working_dir scoping + enqueue→processQueue race fix + QueueItem persistent DB queue + makeCleanEnv PATH augment (3617L) │ │ ├── spawn/ ← spawn 서브모듈 (3 files) │ │ │ ├── queue.ts ← QueueItem persistent DB queue + processQueue race fix + enqueue/dequeue + drainRecoveredQueue (부팅 시 복구 큐 기동, server.ts가 transport 준비 후 호출) + `_fromQueue` 표식 (대기자 없는 턴을 채널이 답할 수 있게) (689L) │ │ │ ├── resume.ts ← session resume logic + stale resume detection (117L) │ │ │ └── process-kill.ts ← child process kill helper (195L) │ │ ├── events/ ← NDJSON 이벤트 파서 모듈 분리 (12 files) -│ │ │ ├── index.ts ← 이벤트 라우터 + logEventSummary + stepRef correlation + compact event parsing + duplicate suppression + claude message_start 경계 (393L) -│ │ │ ├── helpers.ts ← summarizeToolInput(type-safe) + toolType/detail 필드 + flushClaudeBuffers (403L) -│ │ │ ├── claude.ts ← Claude thinking_delta/input_json_delta 버퍼 + content_block_stop flush + 메시지 경계 LAST-WINS (368L) -│ │ │ ├── opencode.ts ← OpenCode event adapter + step 경계 last-step-wins (214L) -│ │ │ ├── grok.ts ← Grok throttled visible thinking + event adapter (369L) -│ │ │ ├── codex.ts ← Codex item.started/completed + toolLog running→done dedup (130L) +│ │ │ ├── index.ts ← 이벤트 라우터 + logEventSummary + stepRef correlation + compact event parsing + duplicate suppression + claude message_start 경계 (403L) +│ │ │ ├── helpers.ts ← summarizeToolInput(type-safe) + toolType/detail 필드 + flushClaudeBuffers (412L) +│ │ │ ├── claude.ts ← Claude thinking_delta/input_json_delta 버퍼 + content_block_stop flush + 메시지 경계 LAST-WINS (371L) +│ │ │ ├── opencode.ts ← OpenCode event adapter + step 경계 last-step-wins (276L) +│ │ │ ├── grok.ts ← Grok throttled visible thinking + event adapter (391L) +│ │ │ ├── codex.ts ← Codex item.started/completed + toolLog running→done dedup (133L) │ │ │ ├── acp.ts ← ACP session/update 이벤트 + agent_message_chunk messageId 보존 (225L) -│ │ │ ├── cursor.ts ← Cursor event adapter + tool/message-boundary LAST-WINS (265L) +│ │ │ ├── cursor.ts ← Cursor event adapter + tool/message-boundary LAST-WINS (290L) │ │ │ ├── gemini.ts ← Gemini event adapter (117L) │ │ │ ├── summary.ts ← event summary formatters (118L) │ │ │ ├── tool-labels.ts ← tool name→label mapping (315L) @@ -113,7 +116,7 @@ cli-jaw/ │ │ ├── agy-capabilities.ts ← AGY `--help`/`--version` capability probe + cached optional flag support map + legacy emit-all fallback marker (124L) │ │ ├── agy-transcript-watcher.ts ← AGY transcript/log watcher and session-id extraction support (291L) │ │ ├── pi-runtime.ts ← Pi profile 정규화 + isolated `PI_CODING_AGENT_DIR` models/settings 생성 + `pi --offline --list-models` discovery + `pi --mode rpc` JSONL parser/spawner (820L) ✨ -│ │ ├── lifecycle-handler.ts ← child lifecycle + fallback/retry + queue resume orchestration + clearEmployeeSession on resume failure + stale resume fresh retry + kickGoalContinuation export + clearGoalTimers + goal continuation boundary row (1398L) +│ │ ├── lifecycle-handler.ts ← child lifecycle + fallback/retry + queue resume orchestration + clearEmployeeSession on resume failure + stale resume fresh retry + kickGoalContinuation export + clearGoalTimers + goal continuation boundary row (1387L) │ │ ├── jwc-runtime.ts ← resident/in-process JWC runtime bridge and event handling (222L) │ │ ├── kiro-auth.ts ← Kiro CLI auth store reader (resolveKiroDataPath, readKiroAuthFromStore, resolveKiroProfileArn, regionFromProfileArn, listKiroConversationIdsForCwd, resolveKiroSessionIdAfterSpawn, extractKiroSessionIdFromV2Store) (253L) │ │ ├── kiro-models.ts ← Kiro live model inventory (KiroModelEntry, KiroModelInventory, parseKiroModelListJson, fetchKiroModelInventory) (98L) @@ -346,7 +349,7 @@ cli-jaw/ │ │ ├── jaw-memory.ts ← jaw memory search/read/list/save/init/reflect/flush/soul/soul-activate/bootstrap 라우트 (362L) │ │ ├── jaw-ceo.ts ← Jaw CEO channel/session support routes (321L) ✨ │ │ ├── i18n.ts ← locale bundle 라우트 (35L) -│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1224L) +│ │ ├── orchestrate.ts ← IPABCD reset/state/workers/worker-runs/snapshot/queue cancel/queue steer async accept/dispatch/virtual dispatch/batch safe summary/worker result/state PUT 라우트 + Phase60 boss-token actor distinction + --attest body gate + single-use pendingAttestation null-clear (1223L) │ │ ├── memory.ts ← memory status/KV/files/settings 라우트 (191L) │ │ ├── settings.ts ← settings/prompt/project pick/git summary/heartbeat-md/MCP/registry/status/quota/copilot + Pi profile register/model discovery 라우트 + CLI_KEYS 기반 quota parity/status-only metadata (754L) │ │ ├── messaging.ts ← upload/file-open/voice/telegram/channel/discord send 라우트 (513L) @@ -372,7 +375,7 @@ cli-jaw/ │ │ ├── async-handler.ts ← asyncHandler 래퍼 (14L) │ │ └── error-middleware.ts ← notFoundHandler, errorHandler (26L) │ ├── types/ ← 공유 타입 정의 (3 files, 329L) -│ │ ├── agent.ts ← ToolEntry, SpawnContext, SpawnResult 인터페이스 (206L) +│ │ ├── agent.ts ← ToolEntry, SpawnContext, SpawnResult 인터페이스 (211L) │ │ ├── cli-engine.ts ← CliEngine union + registry key tuple + `agy`/`ai-e`/`claude-e`/`kiro-code` discriminators (58L) │ │ └── cli-events.ts ← CLI event record/discriminator helpers (154L) │ ├── command-contract/ ← 커맨드 인터페이스 통합 (3 files) @@ -396,7 +399,7 @@ cli-jaw/ │ │ ├── activity-control.ts ← bounded control metadata and best-effort closure (75L) │ │ ├── activity-retention.ts ← whole-prefix expiry and protected active owners (54L) │ │ ├── runtime-body-codec.ts ← canonical runtime body tuples + contextual redaction (144L) -│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (350L) +│ │ ├── store.ts ← startTraceRun + appendTraceEvent + stampTraceTool + finalizeTraceRun + pruneTraceEvents (370L) │ │ ├── retention.ts ← startTraceRetention: boot prune + 6h sweep, {stop(), stopped} 핸들 (server.ts shutdown 이 소유) (22L) │ │ ├── types.ts ← TraceRunInput, TraceEventInput, TracePointer, TraceRunRow 타입 (38L) │ │ └── redact.ts ← trace event redaction helpers (48L) diff --git a/structure/stream-events.md b/structure/stream-events.md index c1cb6e080..afb4563ed 100644 --- a/structure/stream-events.md +++ b/structure/stream-events.md @@ -14,6 +14,12 @@ even if the in-memory SSE ring still accepts a cursor. Loss never substitutes a triggers another send; replay requests are historical. Contracts and limits are in `runtime-integration.md` and `server_api.md`. +Print events share that journal through the existing RuntimeProjection sink. Accepted +provider text precedes legacy resets; lifecycle-selected final remains authoritative. +Legacy `agent_output`/`agent_tool` explicitly stamp captured chat/scope/run identity. +Semantic events never become collect/forwarder/ACK/queue input. On projection failure, +one gap signals a degraded projection and the existing compatibility final remains valid. + > 각 CLI의 NDJSON/ACP/stream-json 이벤트를 `src/agent/events/`가 파싱하고, AGY plain-text output은 `spawn.ts`가 직접 처리한다. X-01 이후 current server의 public Web delivery는 `src/core/event-bus.ts` + `GET /api/events` SSE channel이 담당한다. WebSocket은 current server broadcast path가 아니라 `/api/events`가 한 번도 열리지 않는 pre-X-01 server용 client/TUI fallback이다. > 마지막 코드 대조: 2026-06-27 (`src/core/event-bus.ts`, `src/agent/lifecycle-handler.ts`, `src/goal/heartbeat.ts`, `src/agent/events/claude.ts`, `public/js/features/process-block.ts`, `public/js/ws.ts`) From fbc29528f1c25b214abeacccf30cfaf9cf5b70d5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:29:40 +0900 Subject: [PATCH 27/33] test: keep native spawn fixture aligned with trace recovery API --- tests/unit/codex-app-multiplex-spawn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/codex-app-multiplex-spawn.test.ts b/tests/unit/codex-app-multiplex-spawn.test.ts index c57a12019..16b926d35 100644 --- a/tests/unit/codex-app-multiplex-spawn.test.ts +++ b/tests/unit/codex-app-multiplex-spawn.test.ts @@ -333,7 +333,7 @@ test.mock.module('../../src/trace/store.js', { namedExports: { appendTraceEvent: (entry: Record) => { harness.traceEvents.push(entry); }, stampTraceTool() {}, stampTraceToolEntries() {}, - updateTraceToolRow() {}, getTraceEvent: () => null, linkTraceRunToMessage() {}, + updateTraceToolRow() {}, getTraceEvent: () => null, getTraceToolEntry: () => null, linkTraceRunToMessage() {}, startTraceRun: () => 'tr_multiplexfixture0001', finalizeTraceRun: (runId: string | null | undefined, status: string) => { harness.finalized.push({ runId, status }); From 6f1fba64691a8731ebe385caa4e27a5383cd407a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 04:54:23 +0900 Subject: [PATCH 28/33] test: verify reentrant spawn error settlement behavior --- tests/unit/enoent-guard.test.ts | 24 +++--------------------- tests/unit/print-bypass-paths.test.ts | 12 +++++++++--- tests/unit/print-spawn-journal.test.ts | 13 ++++++++++--- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/tests/unit/enoent-guard.test.ts b/tests/unit/enoent-guard.test.ts index fdedd10b3..c04f1d8d0 100644 --- a/tests/unit/enoent-guard.test.ts +++ b/tests/unit/enoent-guard.test.ts @@ -184,27 +184,9 @@ test('EG-006: acpSettled guard exists in both error and exit handlers', () => { ); }); -// ─── EG-007: settled flags are set before resolve/broadcast ─── - -test('EG-007: settled flag is set before resolve() in error handlers', () => { - // Standard CLI error handler: stdSettled = true must come before resolve - const errorIdx = spawnSrc.indexOf("child.on('error'"); - const errorBlock = spawnSrc.slice(errorIdx, errorIdx + 1400); - const settledIdx = errorBlock.indexOf('stdSettled = true;'); - const resolveIdx = errorBlock.indexOf('resolve!('); - assert.ok(settledIdx > 0, 'stdSettled assignment should exist in error handler'); - assert.ok(resolveIdx > 0, 'resolve should exist in error handler'); - assert.ok(settledIdx < resolveIdx, 'stdSettled = true must come before resolve'); - - // ACP error handler: acpSettled = true must come before resolve - const acpErrorIdx = spawnSrc.indexOf("acp.on('error'"); - const acpErrorBlock = spawnSrc.slice(acpErrorIdx, acpErrorIdx + 800); - const acpSettledIdx = acpErrorBlock.indexOf('acpSettled = true;'); - const acpResolveIdx = acpErrorBlock.indexOf('resolve!('); - assert.ok(acpSettledIdx > 0, 'acpSettled assignment should exist in ACP error handler'); - assert.ok(acpResolveIdx > 0, 'resolve should exist in ACP error handler'); - assert.ok(acpSettledIdx < acpResolveIdx, 'acpSettled = true must come before resolve'); -}); +// EG-007a/b are behavioral probes in print-spawn-journal.test.ts and +// print-bypass-paths.test.ts: reentrant error hooks plus the subsequent close/exit +// must produce one completion. Fixed source windows could not verify that contract. // ─── EG-008: quota-copilot.ts uses env-first token lookup ─── diff --git a/tests/unit/print-bypass-paths.test.ts b/tests/unit/print-bypass-paths.test.ts index 1bd820dba..b0e222a24 100644 --- a/tests/unit/print-bypass-paths.test.ts +++ b/tests/unit/print-bypass-paths.test.ts @@ -18,9 +18,11 @@ const isolatedOs = { ...os, homedir: () => home }; test.mock.module('node:os', { namedExports: isolatedOs, defaultExport: isolatedOs }); assert.equal((await import('os')).default.homedir(), home, 'Copilot config writes must be isolated before importing spawn'); +let lastAcp: ErrorAcp | null = null; class ErrorAcp extends EventEmitter { proc = Object.assign(new EventEmitter(), { pid: undefined, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough() }); - spawn() { queueMicrotask(() => this.emit('error', new Error('fixture ACP failure'))); } + constructor() { super(); lastAcp = this; } + spawn() { queueMicrotask(() => { this.emit('error', new Error('fixture ACP failure')); this.emit('exit', { code: 1, signal: null }); }); } initialize() { return new Promise(() => {}); } kill() {} } @@ -66,13 +68,17 @@ test.beforeEach(t => { config.settings.multiSession.enabled = false; config.settings.activeOverrides = {}; config.settings.fallbackOrder = []; }); -test('Copilot ACP error bypass closes the admitted journal and preserves one existing error completion', { timeout: 10_000 }, async () => { +test('EG-007b: Copilot ACP error reentry and exit preserve one completion and close the journal', { timeout: 10_000 }, async () => { const seen: Array<{ event: string; data: Record }> = []; const unsubscribe = subscribe(e => seen.push(e)); try { + let exits = 0; const result = await spawnAgent('ACP fixture', { cli: 'copilot', model: 'fixture', effort: '', sysPrompt: 'fixture system', origin: 'web', - _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true, + lifecycle: { onExit: () => { exits++; assert.ok(lastAcp); lastAcp.emit('error', new Error('reentrant ACP fixture')); } }, + }).promise; assert.equal(result.code, 1); assert.equal(launches, 0); + assert.equal(exits, 1, 'settled guard precedes reentrant error and subsequent exit'); const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; assert.equal(p.status, 'error'); assert.equal(p.events.at(-1)?.kind, 'turn-end'); diff --git a/tests/unit/print-spawn-journal.test.ts b/tests/unit/print-spawn-journal.test.ts index 91d0373f8..128e28ed0 100644 --- a/tests/unit/print-spawn-journal.test.ts +++ b/tests/unit/print-spawn-journal.test.ts @@ -76,7 +76,7 @@ test('real print child traverses spawn, accepted parser, lifecycle and durable j } finally { unsubscribe(); } }); -test('real asynchronous print spawn failure closes its journal without another attempt', { timeout: 15_000 }, async t => { +test('EG-007a: real print error reentry and close settle only once and close the journal', { timeout: 15_000 }, async t => { t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(console, 'error', () => {}); launchError = true; @@ -84,9 +84,16 @@ test('real asynchronous print spawn failure closes its journal without another a const seen: Array<{ event: string; data: Record }> = []; const unsubscribe = subscribe(event => seen.push(event)); try { - const result = await spawnAgent('fixture error', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', origin: 'web', - _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }).promise; + let child: childProcess.ChildProcess | null = null; + let exits = 0; + const run = spawnAgent('fixture error', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', origin: 'web', + _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true, + lifecycle: { onExit: () => { exits++; assert.ok(child); child.emit('error', new Error('reentrant fixture')); } }, + }); + child = run.child; + const result = await run.promise; assert.equal(result.code, 127); assert.equal(launches, before + 1); + assert.equal(exits, 1, 'settled guard precedes any reentrant lifecycle callback'); const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; assert.ok(start); const p = readActivityPage({ runId: String(start.data['runId']), sessionId: 'default', after: 0, limit: 40 })!; From 2a043b14a531ee1db9c2d51b25e1c86f71daac54 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 05:22:14 +0900 Subject: [PATCH 29/33] docs: archive completed Activity backend evidence --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index fc90553ff..269297537 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit fc90553ff811e99c8b2a9acf29d04616baa414a4 +Subproject commit 269297537d39f5c5e9880849b42457d6eb755e00 From 7f16d31d09442f2cdcadd98e67bf332b78cb1be3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 07:45:36 +0900 Subject: [PATCH 30/33] fix: bind native Cursor trace to captured chat owner --- src/agent/spawn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/spawn.ts b/src/agent/spawn.ts index 6677dbf65..d540ff063 100644 --- a/src/agent/spawn.ts +++ b/src/agent/spawn.ts @@ -1747,7 +1747,7 @@ export function spawnAgent(prompt: string, opts: SpawnOpts = {}): SpawnResult { const capturedRun = mainRun!; const nativeCwd = spawnCwd || process.cwd(); let traceRunId: string; - try { traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: nativeCwd, agentLabel, audience: traceAudience }); } + try { traceRunId = startTraceRun({ cli, model: runtimeModel, workingDir: nativeCwd, agentLabel, audience: traceAudience, sessionId: chatSessionId, scopeKey }); } catch { traceRunId = createTraceId(); console.warn('[runtime:cursor] trace creation unavailable'); } const identity = Object.freeze({ runId: traceRunId, sessionId: chatSessionId, scope: scopeKey, turnId: traceRunId, audience: traceAudience, From 812937df1aaf8b181625a9f8433c8a67e88bf763 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 07:48:06 +0900 Subject: [PATCH 31/33] test: compose native Cursor fixtures with owned Activity journal --- tests/unit/cursor-acp-steer.test.ts | 3 +- tests/unit/native-cursor-spawn.test.ts | 120 +++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/tests/unit/cursor-acp-steer.test.ts b/tests/unit/cursor-acp-steer.test.ts index 9f2775fbe..1dd7c1d92 100644 --- a/tests/unit/cursor-acp-steer.test.ts +++ b/tests/unit/cursor-acp-steer.test.ts @@ -35,6 +35,7 @@ test.mock.module('../../src/orchestrator/gateway.js', { namedExports: { ...gatew const { steerHandler } = await import('../../src/cli/handlers-runtime.ts'); const { withSessionScope } = await import('../../src/core/session-context.ts'); const { db, insertMessage } = await import('../../src/core/db.ts'); +const { createChatSession } = await import('../../src/core/chat-sessions.ts'); const { subscribe } = await import('../../src/core/event-bus.ts'); const { poolStats } = await import('../../src/agent/runtime-pool.ts'); const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); @@ -64,7 +65,7 @@ test.after(() => fs.rmSync(root, { recursive: true, force: true })); function options(target?: RemoteTarget) { const id = ++serial; return { cli: 'cursor', model: 'default', effort: '', origin: 'web', - scopeKey: `control-scope-${id}`, chatSessionId: `control-chat-${id}`, requestId: `control-request-${id}`, + scopeKey: `control-scope-${id}`, chatSessionId: createChatSession(`control-chat-${id}`).id, requestId: `control-request-${id}`, sysPrompt: 'OPERATIONAL_SENTINEL: keep output local.', _skipHistory: true, _isSmokeContinuation: true, ...(target ? { target } : {}) }; } diff --git a/tests/unit/native-cursor-spawn.test.ts b/tests/unit/native-cursor-spawn.test.ts index e4c7e619b..c940d094c 100644 --- a/tests/unit/native-cursor-spawn.test.ts +++ b/tests/unit/native-cursor-spawn.test.ts @@ -4,7 +4,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { createServer, get } from 'node:http'; +import express from 'express'; import type { AcpSession } from '../../src/agent/runtime/acp/session.ts'; +import type { ActivityPage, ActivityRunSummary } from '../../src/trace/activity-journal.ts'; const root = fs.mkdtempSync(join(tmpdir(), 'native-cursor-spawn-')); const binary = join(root, 'cursor-agent.mjs'); @@ -47,11 +50,6 @@ test.mock.module('../../src/agent/runtime/acp/cursor-session.js', { namedExports const session = await factory.createCursorSession(input); sessions.push(session); return session; } } }); const trace = await import('../../src/trace/store.ts'); -let failJournal = false; -test.mock.module('../../src/trace/store.js', { namedExports: { ...trace, - appendTraceEvent: (...args: Parameters) => { - if (failJournal) throw new Error('fixture journal failure'); return trace.appendTraceEvent(...args); - } } }); const { spawnAgent, killActiveAgent, waitForExitSettled, activeMainProcesses, enqueueMessage, messageQueue, removeQueuedMessage } = await import('../../src/agent/spawn.ts'); const { db, getMaxMessageId, getSteerSalvageAfter } = await import('../../src/core/db.ts'); const database = await import('../../src/core/db.ts'); @@ -61,9 +59,12 @@ const { beginLiveRun, setLiveRunTraceId, getLiveRun, appendLiveRunTool } = await const { clearGoalTimers } = await import('../../src/agent/lifecycle-handler.ts'); const { poolStats } = await import('../../src/agent/runtime-pool.ts'); const { beginRuntimeSettingsMutation } = await import('../../src/core/runtime-settings-gate.ts'); +const { createChatSession, forkChatSession, setActiveChatSession } = await import('../../src/core/chat-sessions.ts'); +const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); +const { registerTraceRoutes } = await import('../../src/routes/traces.ts'); let serial = 0; test.beforeEach(t => { - failJournal = false; inputs.length = 0; beforeFactory = undefined; + inputs.length = 0; beforeFactory = undefined; config.settings.cli = 'cursor'; config.settings.workingDir = root; config.settings.projectDirs = [root]; config.settings.permissions = 'auto'; config.settings.fallbackOrder = []; config.settings.activeOverrides = {}; config.settings.perCli = { ...config.settings.perCli, cursor: { model: 'm1', effort: 'low', transport: 'native' } }; @@ -82,9 +83,105 @@ test.after(() => fs.rmSync(root, { recursive: true, force: true })); function options() { const id = ++serial; return { cli: 'cursor', model: 'm1', effort: 'low', origin: 'web', scopeKey: 'native-scope-' + id, - chatSessionId: 'native-chat-' + id, requestId: 'native-request-' + id, + chatSessionId: createChatSession('native-chat-' + id).id, requestId: 'native-request-' + id, sysPrompt: '', _skipHistory: true, _isSmokeContinuation: true }; } + +for (const multiSession of [true, false]) test(`native main journal HTTP preserves exact owner, fork denial and replay (multiSession=${multiSession})`, { timeout: 15_000 }, async () => { + config.settings.multiSession.enabled = multiSession; + const opts = options(); + const other = createChatSession('unrelated-active-chat'); + const app = express(); + registerTraceRoutes(app, (_req, _res, next) => next()); + const server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); assert.ok(address && typeof address === 'object'); + const base = `http://127.0.0.1:${address.port}/api/traces`; + const read = (path: string, status = 200) => new Promise<{ data: T }>((resolve, reject) => { + const req = get(base + path, { signal: AbortSignal.timeout(3_000) }, res => { + let body = ''; + res.setEncoding('utf8'); res.on('data', value => { body += value; }); + res.on('end', () => { + try { + assert.equal(res.statusCode, status, path); + assert.equal(res.headers['cache-control'], 'no-store'); + resolve(JSON.parse(body)); + } catch (error) { reject(error); } + }); + }); + req.on('error', reject); + }); + const ready = Promise.withResolvers(); + const off = subscribe(event => { + if (event.event === 'agent_runtime' && event.data['kind'] === 'message' + && event.data['sessionId'] === opts.chatSessionId) ready.resolve(String(event.data['runId'])); + }); + let pending: ReturnType | undefined; + const timeout = setTimeout(() => ready.reject(new Error('native message not journaled')), 3_000); + try { + pending = spawnAgent('HOLD_NATIVE_FIXTURE', opts); + const heldId = await ready.promise; + clearTimeout(timeout); + const path = `/${heldId}/activity?session=${opts.chatSessionId}`; + const live = (await read(path)).data; + assert.equal(live.status, 'running'); assert.equal(live.scope, opts.scopeKey); + assert.equal(live.sessionId, opts.chatSessionId); assert.ok(live.events.length >= 2); + assert.equal(live.incomplete, false); + assert.ok(live.events.every((event: { sessionId: string; scope: string }) => + event.sessionId === opts.chatSessionId && event.scope === opts.scopeKey)); + await read(`/${heldId}/activity?session=${other.id}`, 404); + await read(`/${heldId}?session=${other.id}`, 404); + await read(`/${heldId}`, 404); + await read(`/${heldId}?session=${opts.chatSessionId}`); + assert.equal(killActiveAgent(opts.scopeKey, 'user'), true); + await pending.promise; pending = undefined; + const frozen = (await read(path + `&through=${live.through}`)).data; + assert.deepEqual(frozen.events, live.events); + assert.equal(frozen.through, live.through); + const stopped = (await read(path + `&after=${live.through}`)).data; + assert.equal(stopped.events.at(-1)?.kind, 'turn-end'); + assert.equal(stopped.events.at(-1)?.status, 'stopped'); + + const result = await spawnAgent('complete journal fixture', opts).promise; + assert.equal(result.text, 'NATIVE_MAIN_FINAL'); assert.ok(result.traceRunId); + const runId = result.traceRunId; + const row = trace.getTraceRun(runId)!; + assert.equal(row.session_id, opts.chatSessionId); assert.equal(row.scope_key, opts.scopeKey); + const message = db.prepare('SELECT content,trace_run_id FROM messages WHERE id=? AND session_id=?').get(row.message_id, opts.chatSessionId); + assert.deepEqual(message, { content: 'NATIVE_MAIN_FINAL', trace_run_id: runId }); + const replayPath = `/${runId}/activity?session=${opts.chatSessionId}`; + const first = (await read(replayPath + '&limit=1')).data; + let page = first; const replay = [...first.events]; + while (page.hasMore) { + const previous = page.nextAfter; + page = (await read(replayPath + `&limit=1&after=${page.nextAfter}&through=${first.through}`)).data; + assert.ok(page.nextAfter > previous, 'fixed-through replay must advance'); + assert.equal(page.through, first.through); replay.push(...page.events); + } + assert.equal(page.incomplete, false); + assert.equal(replay.filter(event => event.kind === 'turn-end').length, 1); + const end = replay.at(-1); assert.ok(end?.kind === 'turn-end'); + assert.equal(end.finalText, 'NATIVE_MAIN_FINAL'); + assert.ok(replay.some(event => event.kind === 'tool')); + assert.doesNotMatch(JSON.stringify(replay), /private-native-session|private-tool/); + const listed = (await read<{ runs: ActivityRunSummary[] }>(`/activity-runs?session=${opts.chatSessionId}`)).data.runs; + assert.ok(listed.some((run: { id: string }) => run.id === runId)); + const fork = forkChatSession(opts.chatSessionId); + assert.ok(fork.copiedCount > 0); + assert.ok(db.prepare('SELECT id FROM messages WHERE session_id=? AND trace_run_id=?').get(fork.id, runId)); + await read(`/${runId}/activity?session=${fork.id}`, 404); + await read(`/${runId}?session=${fork.id}`, 404); + assert.deepEqual((await read<{ runs: ActivityRunSummary[] }>(`/activity-runs?session=${fork.id}`)).data.runs, []); + await read(replayPath); + } finally { + clearTimeout(timeout); off(); + if (pending) { killActiveAgent(opts.scopeKey, 'user'); await pending.promise; } + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + setActiveChatSession('default'); + } +}); + test('main actual factory/protocol/pool/lifecycle produces final-only MESSAGE and correlated native events', async () => { const opts = options(), events: Array<{ type: string; data: Record }> = []; const off = subscribe(event => events.push({ type: event.event, data: event.data })); @@ -106,7 +203,9 @@ test('main actual factory/protocol/pool/lifecycle produces final-only MESSAGE an } finally { off(); } }); test('journal failure cannot suppress full final, scoped non-text I/O liveness or completion', async () => { - failJournal = true; const opts = options(); const liveness: Record[] = []; + const opts = options(); const liveness: Record[] = []; + db.exec("CREATE TRIGGER cursor_journal_fault BEFORE INSERT ON trace_events WHEN new.source='runtime' BEGIN SELECT RAISE(ABORT,'fixture journal failure'); END"); + try { const result = await spawnAgent('fixture', { ...opts, lifecycle: { onActivity: (source, identity) => { assert.equal(source, 'native-runtime'); liveness.push(identity!); }, onExit: () => { throw new Error('observer-only failure'); }, @@ -115,6 +214,11 @@ test('journal failure cannot suppress full final, scoped non-text I/O liveness o assert.ok(liveness.length > 0); assert.ok(liveness.every(value => value.scope === opts.scopeKey && value.sessionId === opts.chatSessionId && value.requestId === opts.requestId)); assert.deepEqual(db.prepare('SELECT content FROM messages WHERE session_id=? AND role=?').all(opts.chatSessionId, 'assistant'), [{ content: 'NATIVE_MAIN_FINAL' }]); + const run = db.prepare('SELECT id FROM trace_runs WHERE session_id=?').get(opts.chatSessionId) as { id: string }; + const page = readActivityPage({ runId: run.id, sessionId: opts.chatSessionId, after: 0, limit: 40 }); + assert.ok(page?.incomplete, 'real runtime insert failure reached durable loss reporting'); + assert.deepEqual(page.events, []); + } finally { db.exec('DROP TRIGGER cursor_journal_fault'); } }); test('native kill-steer preserves partial MESSAGE before the exact exit barrier', async () => { const opts = options(), watermark = getMaxMessageId(opts.chatSessionId); From f1ebbfb43878c25e616b952fa755b160a540fce2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 07:49:18 +0900 Subject: [PATCH 32/33] test: preserve captured print binding across parent cascade --- structure/runtime-integration.md | 4 ++++ tests/unit/print-spawn-journal.test.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/structure/runtime-integration.md b/structure/runtime-integration.md index 7c2c36f2b..2ee3821e9 100644 --- a/structure/runtime-integration.md +++ b/structure/runtime-integration.md @@ -30,6 +30,10 @@ admission, including internal workers. Historical backfill uses only the origina not grant access. Deleting a chat removes its owned traces. Clearing messages alone does not securely erase retained trace history. +Native Cursor main captures these same owner fields at its trace admission, alongside +the four existing Copilot/Pi/Codex App/ordinary print admissions. Explicit captured +execution bindings remain authoritative even when multi-session is disabled. + `trace/activity-journal.ts` commits a bounded body and one mutable control row atomically before SSE publication. Limits are32KiB/body,4096 rows/4MiB/run,20000 rows/32MiB global, plus configured trace row admission. Loss closes projection admission without interrupting diff --git a/tests/unit/print-spawn-journal.test.ts b/tests/unit/print-spawn-journal.test.ts index 128e28ed0..47728c650 100644 --- a/tests/unit/print-spawn-journal.test.ts +++ b/tests/unit/print-spawn-journal.test.ts @@ -38,6 +38,7 @@ const { spawnAgent, activeMainProcesses, activeProcesses } = await import('../.. const { subscribe } = await import('../../src/core/event-bus.ts'); const { readActivityPage } = await import('../../src/trace/activity-journal.ts'); const { db } = await import('../../src/core/db.ts'); +const { createChatSession, setActiveChatSession } = await import('../../src/core/chat-sessions.ts'); test('real print child traverses spawn, accepted parser, lifecycle and durable journal with captured identity', { timeout: 15_000 }, async t => { t.mock.method(globalThis, 'fetch', async () => { throw new Error('unexpected network'); }); @@ -49,16 +50,19 @@ test('real print child traverses spawn, accepted parser, lifecycle and durable j mkdirSync(join(home, 'prompts'), { recursive: true }); const seen: Array<{ event: string; data: Record }> = []; const unsubscribe = subscribe(event => seen.push(event)); + const owner = createChatSession('captured-print-owner'); + const scope = 'captured-print-scope'; + setActiveChatSession('default'); try { const run = spawnAgent('fixture input', { cli: 'codex', model: 'fixture', sysPrompt: 'fixture system', - scopeKey: 'ignored-scope', chatSessionId: 'ignored-chat', origin: 'web', + scopeKey: scope, chatSessionId: owner.id, origin: 'web', _skipInsert: true, _skipHistory: true, _skipResume: true, _skipSessionPersist: true, _isSmokeContinuation: true }); const result = await run.promise; assert.equal(result.code, 0); assert.equal(launches, 1); const start = seen.find(e => e.event === 'agent_runtime' && e.data['kind'] === 'turn-start')!; assert.ok(start); const runId = String(start.data['runId']); - const replay = readActivityPage({ runId, sessionId: 'default', after: 0, limit: 40 })!; + const replay = readActivityPage({ runId, sessionId: owner.id, after: 0, limit: 40 })!; assert.equal(replay.incomplete, false); const end = replay.events.at(-1); assert.ok(end?.kind === 'turn-end'); assert.equal(end.finalText, 'print fixture final'); assert.ok(replay.events.some(e => e.kind === 'message' && e.phase === 'commentary' && e.text === 'kept commentary')); @@ -66,7 +70,7 @@ test('real print child traverses spawn, accepted parser, lifecycle and durable j assert.ok(!JSON.stringify(replay).includes('stderr-not-an-assistant-message')); for (const packet of seen.filter(e => e.event === 'agent_output' || e.event === 'agent_tool')) { assert.equal(packet.data['traceRunId'], runId); - assert.equal(packet.data['sessionId'], 'default'); assert.equal(packet.data['scope'], 'default'); + assert.equal(packet.data['sessionId'], owner.id); assert.equal(packet.data['scope'], scope); } assert.ok(seen.some(e => e.event === 'agent_output')); assert.ok(seen.some(e => e.event === 'agent_tool')); From 43d05fc5bddd1eb941b5b1374effbc96c52eb262 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 6 Sep 2026 07:50:59 +0900 Subject: [PATCH 33/33] docs: record completed Cursor journal composition --- devlog | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog b/devlog index e90e544f9..e0ff8c903 160000 --- a/devlog +++ b/devlog @@ -1 +1 @@ -Subproject commit e90e544f90142bcb1d530b40851a26f7fe2bd06b +Subproject commit e0ff8c9033c76c3b69823c259eddc81c86a7da65