diff --git a/marketplace/plugins/zcode/scripts/lib/review.mjs b/marketplace/plugins/zcode/scripts/lib/review.mjs index 0019292e..5b5e5830 100644 --- a/marketplace/plugins/zcode/scripts/lib/review.mjs +++ b/marketplace/plugins/zcode/scripts/lib/review.mjs @@ -326,9 +326,15 @@ async function defaultSyncDirectory(path) { export function extractFinalResult(snapshot, command, turnBoundary = {}) { const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; const beforeMessageIds = turnBoundary.beforeMessageIds ?? new Set(); - const newAssistants = messages.filter((/** @type {any} */ message) => message?.info?.role === 'assistant' && typeof message.info.messageId === 'string' && !beforeMessageIds.has(message.info.messageId)); - const linkedAssistants = turnBoundary.inputId ? newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === turnBoundary.inputId) : []; - const assistant = (turnBoundary.inputId ? linkedAssistants : newAssistants).at(-1); + const newAssistants = messages.filter((/** @type {any} */ message) => isAssistantResponse(message, beforeMessageIds)); + const directAssistants = turnBoundary.inputId ? newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === turnBoundary.inputId) : []; + let assistant; + if (directAssistants.length) assistant = directAssistants.at(-1); + else if (turnBoundary.inputId) { + const currentUserRoots = messages.filter((/** @type {any} */ message) => isCurrentUserRoot(message, beforeMessageIds)); + if (currentUserRoots.length !== 1) throw missingResult(); + assistant = newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === currentUserRoots[0].info.messageId).at(-1); + } else assistant = newAssistants.at(-1); if (['hidden', 'debug'].includes(assistant?.info?.semantics?.uiVisibility)) throw missingResult(); const parts = assistant?.parts?.filter((/** @type {any} */ part) => part?.type === 'text' && part.ignored !== true && typeof part.text === 'string' && part.text.length > 0).map((/** @type {any} */ part) => part.text) ?? []; if (!parts.length) throw missingResult(); @@ -341,6 +347,18 @@ export function extractFinalResult(snapshot, command, turnBoundary = {}) { if (!validateJsonSchema(structured, REVIEW_OUTPUT_SCHEMA)) throw invalidReviewResult(); return `${JSON.stringify(structured, null, 2)}\n`; } +/** @param {any} message @param {Set} beforeMessageIds */ +function isAssistantResponse(message, beforeMessageIds) { + const semantics = message?.info?.semantics; + return message?.info?.role === 'assistant' && typeof message.info.messageId === 'string' && !beforeMessageIds.has(message.info.messageId) + && (semantics === undefined || semantics.origin === 'agent_runtime' && semantics.kind === 'assistant_response'); +} +/** @param {any} message @param {Set} beforeMessageIds */ +function isCurrentUserRoot(message, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'user' && typeof info.messageId === 'string' && !beforeMessageIds.has(info.messageId) && info.synthetic !== true && info.visibility !== 'model-only' && info.source === undefined + && (semantics === undefined || semantics.origin === 'real_user' && semantics.kind === 'user_prompt' && semantics.uiVisibility === 'visible'); +} /** @param {any} snapshot */ function snapshotMessageIds(snapshot) { return new Set((Array.isArray(snapshot?.messages) ? snapshot.messages : []).map((/** @type {any} */ message) => message?.info?.messageId).filter((/** @type {unknown} */ value) => typeof value === 'string')); } function missingResult() { return new PluginError('ZCODE_RESULT_MISSING', 'ZCode completed without a visible result for the current turn.', { category: 'protocol', remedy: 'Inspect the ZCode session and retry.' }); } diff --git a/marketplace/plugins/zcode/scripts/zcode-broker.mjs b/marketplace/plugins/zcode/scripts/zcode-broker.mjs index ba57e352..8015eaf6 100644 --- a/marketplace/plugins/zcode/scripts/zcode-broker.mjs +++ b/marketplace/plugins/zcode/scripts/zcode-broker.mjs @@ -12,7 +12,7 @@ import { PluginError } from './lib/errors.mjs'; import { atomicWriteJson, ensurePrivateDirectory, withFileLock } from './lib/fs.mjs'; import { isBoundedPublicIdentifier, isSafeIdentifier } from './lib/identifier.mjs'; import { spawnDaemon } from './lib/process.mjs'; -import { validSnapshot } from './lib/zcode-schema.mjs'; +import { validCreateSnapshot } from './lib/zcode-schema.mjs'; import { BoundedWriter, closeProtocolUntil, connectZCodeBroker, MAX_DRAIN_TIMEOUT_MS, spawnZCodeProtocol } from './lib/zcode-protocol.mjs'; import { resolveWorkspaceStorage } from './lib/workspace.mjs'; @@ -374,7 +374,7 @@ export class ZCodeBroker { } catch (error) { if (frame.method === 'session/send') { protocol.abortTurn(frame.params.sessionId); this.settleTurnPermissions(frame.params.sessionId, sendToken); if (this.activeSessionSockets.get(frame.params.sessionId)?.token === sendToken) this.activeSessionSockets.delete(frame.params.sessionId); if (this.admittingSessions.get(frame.params.sessionId) === sendToken) this.admittingSessions.delete(frame.params.sessionId); this.activeSessions.delete(frame.params.sessionId); this.scheduleIdleShutdown(); } if (subscriptionToken && this.pendingConversationTopics.get(frame.params.topic)?.token === subscriptionToken) this.pendingConversationTopics.delete(frame.params.topic); throw error; } if (frame.method === 'session/create') { const createdSessionId = result?.session?.sessionId; - if (!isSafeIdentifier(createdSessionId) || typeof requestedSessionId === 'string' && createdSessionId !== requestedSessionId || !validSnapshot(result, createdSessionId, this.options.workspace)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } + if (!isSafeIdentifier(createdSessionId) || typeof requestedSessionId === 'string' && createdSessionId !== requestedSessionId || !validCreateSnapshot(result, createdSessionId, this.options.workspace)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } const anonymousCreate = typeof requestedSessionId !== 'string'; if (anonymousCreate && this.sessionOwners.has(createdSessionId)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } if (!this.admission.ownerRequestCurrent(ownerAdmission)) throw brokerInputError(); diff --git a/scripts/lib/review.mjs b/scripts/lib/review.mjs index 0019292e..5b5e5830 100644 --- a/scripts/lib/review.mjs +++ b/scripts/lib/review.mjs @@ -326,9 +326,15 @@ async function defaultSyncDirectory(path) { export function extractFinalResult(snapshot, command, turnBoundary = {}) { const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; const beforeMessageIds = turnBoundary.beforeMessageIds ?? new Set(); - const newAssistants = messages.filter((/** @type {any} */ message) => message?.info?.role === 'assistant' && typeof message.info.messageId === 'string' && !beforeMessageIds.has(message.info.messageId)); - const linkedAssistants = turnBoundary.inputId ? newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === turnBoundary.inputId) : []; - const assistant = (turnBoundary.inputId ? linkedAssistants : newAssistants).at(-1); + const newAssistants = messages.filter((/** @type {any} */ message) => isAssistantResponse(message, beforeMessageIds)); + const directAssistants = turnBoundary.inputId ? newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === turnBoundary.inputId) : []; + let assistant; + if (directAssistants.length) assistant = directAssistants.at(-1); + else if (turnBoundary.inputId) { + const currentUserRoots = messages.filter((/** @type {any} */ message) => isCurrentUserRoot(message, beforeMessageIds)); + if (currentUserRoots.length !== 1) throw missingResult(); + assistant = newAssistants.filter((/** @type {any} */ message) => message.info.parentMessageId === currentUserRoots[0].info.messageId).at(-1); + } else assistant = newAssistants.at(-1); if (['hidden', 'debug'].includes(assistant?.info?.semantics?.uiVisibility)) throw missingResult(); const parts = assistant?.parts?.filter((/** @type {any} */ part) => part?.type === 'text' && part.ignored !== true && typeof part.text === 'string' && part.text.length > 0).map((/** @type {any} */ part) => part.text) ?? []; if (!parts.length) throw missingResult(); @@ -341,6 +347,18 @@ export function extractFinalResult(snapshot, command, turnBoundary = {}) { if (!validateJsonSchema(structured, REVIEW_OUTPUT_SCHEMA)) throw invalidReviewResult(); return `${JSON.stringify(structured, null, 2)}\n`; } +/** @param {any} message @param {Set} beforeMessageIds */ +function isAssistantResponse(message, beforeMessageIds) { + const semantics = message?.info?.semantics; + return message?.info?.role === 'assistant' && typeof message.info.messageId === 'string' && !beforeMessageIds.has(message.info.messageId) + && (semantics === undefined || semantics.origin === 'agent_runtime' && semantics.kind === 'assistant_response'); +} +/** @param {any} message @param {Set} beforeMessageIds */ +function isCurrentUserRoot(message, beforeMessageIds) { + const info = message?.info; const semantics = info?.semantics; + return info?.role === 'user' && typeof info.messageId === 'string' && !beforeMessageIds.has(info.messageId) && info.synthetic !== true && info.visibility !== 'model-only' && info.source === undefined + && (semantics === undefined || semantics.origin === 'real_user' && semantics.kind === 'user_prompt' && semantics.uiVisibility === 'visible'); +} /** @param {any} snapshot */ function snapshotMessageIds(snapshot) { return new Set((Array.isArray(snapshot?.messages) ? snapshot.messages : []).map((/** @type {any} */ message) => message?.info?.messageId).filter((/** @type {unknown} */ value) => typeof value === 'string')); } function missingResult() { return new PluginError('ZCODE_RESULT_MISSING', 'ZCode completed without a visible result for the current turn.', { category: 'protocol', remedy: 'Inspect the ZCode session and retry.' }); } diff --git a/scripts/zcode-broker.mjs b/scripts/zcode-broker.mjs index ba57e352..8015eaf6 100644 --- a/scripts/zcode-broker.mjs +++ b/scripts/zcode-broker.mjs @@ -12,7 +12,7 @@ import { PluginError } from './lib/errors.mjs'; import { atomicWriteJson, ensurePrivateDirectory, withFileLock } from './lib/fs.mjs'; import { isBoundedPublicIdentifier, isSafeIdentifier } from './lib/identifier.mjs'; import { spawnDaemon } from './lib/process.mjs'; -import { validSnapshot } from './lib/zcode-schema.mjs'; +import { validCreateSnapshot } from './lib/zcode-schema.mjs'; import { BoundedWriter, closeProtocolUntil, connectZCodeBroker, MAX_DRAIN_TIMEOUT_MS, spawnZCodeProtocol } from './lib/zcode-protocol.mjs'; import { resolveWorkspaceStorage } from './lib/workspace.mjs'; @@ -374,7 +374,7 @@ export class ZCodeBroker { } catch (error) { if (frame.method === 'session/send') { protocol.abortTurn(frame.params.sessionId); this.settleTurnPermissions(frame.params.sessionId, sendToken); if (this.activeSessionSockets.get(frame.params.sessionId)?.token === sendToken) this.activeSessionSockets.delete(frame.params.sessionId); if (this.admittingSessions.get(frame.params.sessionId) === sendToken) this.admittingSessions.delete(frame.params.sessionId); this.activeSessions.delete(frame.params.sessionId); this.scheduleIdleShutdown(); } if (subscriptionToken && this.pendingConversationTopics.get(frame.params.topic)?.token === subscriptionToken) this.pendingConversationTopics.delete(frame.params.topic); throw error; } if (frame.method === 'session/create') { const createdSessionId = result?.session?.sessionId; - if (!isSafeIdentifier(createdSessionId) || typeof requestedSessionId === 'string' && createdSessionId !== requestedSessionId || !validSnapshot(result, createdSessionId, this.options.workspace)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } + if (!isSafeIdentifier(createdSessionId) || typeof requestedSessionId === 'string' && createdSessionId !== requestedSessionId || !validCreateSnapshot(result, createdSessionId, this.options.workspace)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } const anonymousCreate = typeof requestedSessionId !== 'string'; if (anonymousCreate && this.sessionOwners.has(createdSessionId)) { this.clearProtocolGeneration(protocol); throw invalidSessionCreateResult(); } if (!this.admission.ownerRequestCurrent(ownerAdmission)) throw brokerInputError(); diff --git a/tests/fixtures/fake-zcode-cli.mjs b/tests/fixtures/fake-zcode-cli.mjs index 4d4d6fff..49484034 100644 --- a/tests/fixtures/fake-zcode-cli.mjs +++ b/tests/fixtures/fake-zcode-cli.mjs @@ -213,9 +213,20 @@ input.on('line', async (line) => { try { objectiveResult = `authorized:${JSON.parse(encoded)}`; } catch { objectiveResult = 'authorized-objective-missing'; } } const session = sessions.get(p.sessionId); if (session) { - const review = /ZCODE_REVIEW_OUTPUT_SCHEMA:\s*\{/i.test(trustedPrompt); const suffix = `turn-${sendCount}`; const inputId = /current unrelated/i.test(p.content) ? 'input-unrelated' : p.inputId; + const review = /ZCODE_REVIEW_OUTPUT_SCHEMA:\s*\{/i.test(trustedPrompt); const suffix = `turn-${sendCount}`; + const linkageMode = /current unrelated/i.test(p.content) ? 'orphan-assistant' : /current distinct id/i.test(p.content) ? 'distinct-user' : 'direct-input'; + const inputId = linkageMode === 'direct-input' ? p.inputId : undefined; if (process.env.FAKE_ZCODE_RECOVERY_CONTROL) { session.messages.push(...messages(p.sessionId, session.settings.model.current)); session.pendingResult = { review, suffix, inputId }; } - else session.messages.push(...resultMessages(p.sessionId, session.settings.model.current, review, suffix, /current hidden/i.test(p.content) ? 'reasoning-only' : undefined, inputId, objectiveResult)); + else { + let turnMessages = resultMessages(p.sessionId, session.settings.model.current, review, suffix, /current hidden/i.test(p.content) ? 'reasoning-only' : undefined, inputId, objectiveResult); + if (linkageMode === 'orphan-assistant') turnMessages = turnMessages.slice(1); + if (linkageMode === 'distinct-user') { + turnMessages[0].info.semantics = { origin: 'real_user', kind: 'user_prompt', uiVisibility: 'visible', providerVisibility: 'visible', transcriptVisibility: 'visible' }; + turnMessages[1].info.semantics = { origin: 'agent_runtime', kind: 'assistant_response', uiVisibility: 'visible', providerVisibility: 'visible', transcriptVisibility: 'visible' }; + if (process.env.FAKE_ZCODE_LINKAGE_RECORD) await writeFile(process.env.FAKE_ZCODE_LINKAGE_RECORD, JSON.stringify({ inputId: p.inputId, userMessageId: turnMessages[0].info.messageId, assistantParentMessageId: turnMessages[1].info.parentMessageId })); + } + session.messages.push(...turnMessages); + } } const stateRevision = process.env.FAKE_ZCODE_BARRIER === '1' ? 1000 : 1; if (session) session.stateRevision = stateRevision; diff --git a/tests/integration/companion.test.mjs b/tests/integration/companion.test.mjs index b7fe1a62..8e533c8a 100644 --- a/tests/integration/companion.test.mjs +++ b/tests/integration/companion.test.mjs @@ -1000,6 +1000,14 @@ test('resumed rescue rejects an unrelated-only new assistant result', async () = assert.equal(jobs.filter((/** @type {any} */ job) => job.status === 'failed').length, 1); }); +test('foreground rescue accepts a 0.16.3 result linked through a distinct user message id', async () => { + const context = await fixture(); const linkageRecord = join(context.directory, 'distinct-linkage.json'); + const result = await companion(context, ['rescue', '--fresh', 'current distinct id'], { FAKE_ZCODE_VERSION: '0.16.3', FAKE_ZCODE_GATE_RESULT: 'distinct-id result', FAKE_ZCODE_LINKAGE_RECORD: linkageRecord }); + assert.equal(result.code, 0, `${result.stderr}${result.stdout}`); assert.equal(result.json.job.status, 'succeeded'); assert.equal(result.json.result, 'distinct-id result'); + const linkage = JSON.parse(await readFile(linkageRecord, 'utf8')); + assert.notEqual(linkage.inputId, linkage.userMessageId); assert.equal(linkage.assistantParentMessageId, linkage.userMessageId); +}); + test('foreground launch failure durably fails its reserved job', async () => { const context = await fixture(); const failed = await companion(context, ['review'], { FAKE_ZCODE_VERSION: '0.1.0' }); diff --git a/tests/permissions.test.mjs b/tests/permissions.test.mjs index dfa483eb..07b394b9 100644 --- a/tests/permissions.test.mjs +++ b/tests/permissions.test.mjs @@ -66,6 +66,12 @@ function assistant(parts, structured, semantics, messageId = 'assistant-current' return { info: { role: 'assistant', messageId, parentMessageId, ...(structured === undefined ? {} : { structured }), ...(semantics === undefined ? {} : { semantics }) }, parts }; } +/** @param {string} messageId @param {Record} [info] */ +function user(messageId, info = {}) { return { info: { role: 'user', messageId, ...info }, parts: [{ type: 'text', text: 'prompt' }] }; } + +/** @param {string} origin @param {string} kind @param {string} [uiVisibility] */ +function semantics(origin, kind, uiVisibility = 'visible') { return { origin, kind, uiVisibility, providerVisibility: 'visible', transcriptVisibility: 'visible' }; } + test('review result prefers valid structured findings anchored by visible final text', () => { const structured = { findings: [{ severity: 'high', file: 'src/a.js', line: 7, evidence: 'boom', fix: 'repair' }] }; const snapshot = { messages: [assistant([ @@ -107,10 +113,113 @@ test('current-turn result accepts a newly added visible structured assistant mes }); test('current-turn result prefers assistant messages linked to the send input over unrelated new messages', () => { - const snapshot = { messages: [assistant([{ type: 'text', text: 'current' }], undefined, undefined, 'assistant-current', 'input-current'), assistant([{ type: 'text', text: 'unrelated' }], undefined, undefined, 'assistant-other', 'input-other')] }; + const snapshot = { messages: [assistant([{ type: 'text', text: 'current' }], undefined, undefined, 'assistant-current', 'input-current'), user('user-other'), assistant([{ type: 'text', text: 'unrelated' }], undefined, undefined, 'assistant-other', 'user-other')] }; + assert.equal(extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), 'current'); +}); + +test('current-turn result follows a new user message when send input id differs from its message id', () => { + const snapshot = { messages: [ + user('user-current', { synthetic: false, visibility: 'user-visible', semantics: semantics('real_user', 'user_prompt') }), + assistant([{ type: 'text', text: 'current' }], undefined, semantics('agent_runtime', 'assistant_response'), 'assistant-current', 'user-current'), + assistant([{ type: 'text', text: 'unrelated' }], undefined, undefined, 'assistant-other', 'user-other'), + ] }; assert.equal(extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), 'current'); }); +test('current-turn result keeps legacy distinct-id linkage without semantics or source', () => { + const snapshot = { messages: [user('legacy-user'), assistant([{ type: 'text', text: 'legacy result' }], undefined, undefined, 'legacy-assistant', 'legacy-user')] }; + assert.equal(extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), 'legacy result'); +}); + +test('current-turn result rejects ambiguous new real prompt roots instead of guessing the last one', () => { + const snapshot = { messages: [ + user('user-first'), + assistant([{ type: 'text', text: 'first' }], undefined, undefined, 'assistant-first', 'user-first'), + user('user-second'), + assistant([{ type: 'text', text: 'second' }], undefined, undefined, 'assistant-second', 'user-second'), + ] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), { code: 'ZCODE_RESULT_MISSING' }); +}); + +test('current-turn result rejects multiple new real prompt roots even when only one has a response', () => { + const snapshot = { messages: [ + user('user-without-response'), + user('user-with-response'), + assistant([{ type: 'text', text: 'must not guess' }], undefined, undefined, 'assistant-current', 'user-with-response'), + ] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), { code: 'ZCODE_RESULT_MISSING' }); +}); + +test('current-turn result excludes synthetic, model-only and background prompt roots', () => { + const boundary = { beforeMessageIds: new Set(), inputId: 'input-current' }; + const cases = [ + user('user-synthetic', { synthetic: true }), + user('user-model-only', { visibility: 'model-only' }), + user('user-background', { semantics: semantics('agent_runtime', 'background_notification') }), + user('user-hidden-prompt', { semantics: semantics('real_user', 'user_prompt', 'hidden') }), + ]; + for (const root of cases) { + const rootId = root.info.messageId; + const response = assistant([{ type: 'text', text: 'unrelated' }], undefined, semantics('agent_runtime', 'assistant_response'), `assistant-${rootId}`, rootId); + assert.throws(() => extractFinalResult({ messages: [root, response] }, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); + } +}); + +test('current-turn result excludes legacy user messages with a background source', () => { + const snapshot = { messages: [ + user('user-background-source', { source: 'background_task' }), + assistant([{ type: 'text', text: 'background' }], undefined, undefined, 'assistant-background', 'user-background-source'), + ] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), { code: 'ZCODE_RESULT_MISSING' }); +}); + +test('current-turn result rejects direct and indirect assistants with non-response semantics', () => { + const indirect = { messages: [ + user('user-current', { semantics: semantics('real_user', 'user_prompt') }), + assistant([{ type: 'text', text: 'background' }], undefined, semantics('agent_runtime', 'background_notification'), 'assistant-background', 'user-current'), + ] }; + const direct = { messages: [assistant([{ type: 'text', text: 'background' }], undefined, semantics('agent_runtime', 'background_notification'), 'assistant-background', 'input-current')] }; + const boundary = { beforeMessageIds: new Set(), inputId: 'input-current' }; + assert.throws(() => extractFinalResult(indirect, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); + assert.throws(() => extractFinalResult(direct, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); +}); + +test('direct input linkage locks hidden or empty results without falling back to an indirect root', () => { + const boundary = { beforeMessageIds: new Set(), inputId: 'input-current' }; + for (const direct of [ + assistant([{ type: 'text', text: 'hidden' }], undefined, semantics('agent_runtime', 'assistant_response', 'hidden'), 'assistant-direct-hidden', 'input-current'), + assistant([{ type: 'text', text: '' }], undefined, semantics('agent_runtime', 'assistant_response'), 'assistant-direct-empty', 'input-current'), + ]) { + const snapshot = { messages: [user('user-other'), assistant([{ type: 'text', text: 'fallback' }], undefined, undefined, 'assistant-other', 'user-other'), direct] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); + } +}); + +test('the last response on the unique prompt root is locked even when hidden or empty', () => { + const boundary = { beforeMessageIds: new Set(), inputId: 'input-current' }; + for (const last of [ + assistant([{ type: 'text', text: 'hidden' }], undefined, semantics('agent_runtime', 'assistant_response', 'hidden'), 'assistant-last-hidden', 'user-current'), + assistant([{ type: 'text', text: '' }], undefined, semantics('agent_runtime', 'assistant_response'), 'assistant-last-empty', 'user-current'), + ]) { + const snapshot = { messages: [user('user-current'), assistant([{ type: 'text', text: 'stale visible' }], undefined, undefined, 'assistant-first', 'user-current'), last] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); + } +}); + +test('current-turn result does not follow an assistant linked to a historical user message', () => { + const snapshot = { messages: [ + user('user-historical'), + assistant([{ type: 'text', text: 'historical continuation' }], undefined, undefined, 'assistant-new', 'user-historical'), + ] }; + assert.throws(() => extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(['user-historical']), inputId: 'input-current' }), { code: 'ZCODE_RESULT_MISSING' }); +}); + +test('indirect current-turn linkage still rejects hidden and empty assistant results', () => { + const boundary = { beforeMessageIds: new Set(), inputId: 'input-current' }; + assert.throws(() => extractFinalResult({ messages: [user('user-hidden'), assistant([{ type: 'text', text: 'hidden' }], undefined, { uiVisibility: 'hidden' }, 'assistant-hidden', 'user-hidden')] }, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); + assert.throws(() => extractFinalResult({ messages: [user('user-empty'), assistant([{ type: 'text', text: '' }], undefined, undefined, 'assistant-empty', 'user-empty')] }, 'rescue', boundary), { code: 'ZCODE_RESULT_MISSING' }); +}); + test('current-turn result rejects unrelated new assistants when input linkage is available', () => { const snapshot = { messages: [assistant([{ type: 'text', text: 'unrelated' }], undefined, undefined, 'assistant-other', 'input-other')] }; assert.throws(() => extractFinalResult(snapshot, 'rescue', { beforeMessageIds: new Set(), inputId: 'input-current' }), { code: 'ZCODE_RESULT_MISSING' }); diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 47014ab4..fceebf22 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -215,6 +215,24 @@ async function withClient(callback, env = {}, options = {}) { try { await callback(client, record); } finally { await client.close(); await rm(directory, { recursive: true, force: true }); } } +async function withFreshManagedClient(callback, env = {}) { + const directory = await mkdtemp(join(tmpdir(), 'zcode-managed-client-')); + const launch = { command: process.execPath, args: [fixture], target: fixture }; + const storage = await resolveWorkspaceStorage({ dataRoot: directory, workspace: directory }); + const identityPath = join(storage.directory, 'broker', 'identity.json'); + let client; let identity; + try { + client = await createManagedZCodeClient({ dataRoot: directory, workspace: directory, launch, ownerId: 'fresh-managed-client-owner', env: { ...process.env, ...env } }); + await callback(client, directory); + } finally { + try { identity = JSON.parse(await readFile(identityPath, 'utf8')); } catch { /* broker did not publish an identity */ } + await client?.close().catch(() => {}); + if (identity?.pid && processAlive(identity.pid)) try { process.kill(identity.pid, 'SIGTERM'); } catch { /* already exited */ } + if (identity?.pid) await waitForProcessExit(identity.pid); + await rm(directory, { recursive: true, force: true }); + } +} + test('typed operations use real 0.16.1 method and parameter shapes', async () => { await withClient(async (client, record) => { const model = { providerId: 'zai', modelId: 'glm-5', variant: 'fast' }; @@ -246,6 +264,21 @@ test('ordinary session/create accepts the bounded 0.16.1 initial empty-session s }, { FAKE_ZCODE_EMPTY_SESSION: '1' }); }); +test('fresh managed broker session/create accepts the bounded initial empty-session snapshot', async () => { + await withFreshManagedClient(async (client, directory) => { + const created = await client.createSession({ workspace: directory }); + assert.equal(created.session.sessionId, 'session-1'); + assert.equal(created.projection.sessionId, 'unknown'); + assert.deepEqual(created.messages, []); + }, { FAKE_ZCODE_EMPTY_SESSION: '1' }); +}); + +test('fresh managed broker session/create rejects conflicting or non-empty unknown-projection snapshots', async (t) => { + for (const variant of ['conflict', 'non-idle', 'event-seq', 'messages', 'target']) await t.test(variant, () => withFreshManagedClient(async (client, directory) => { + await assert.rejects(client.createSession({ workspace: directory }), { code: 'ZCODE_OUTPUT_INVALID' }); + }, { FAKE_ZCODE_EMPTY_SESSION: '1', FAKE_ZCODE_EMPTY_SESSION_VARIANT: variant })); +}); + test('ordinary session/create rejects conflicting or non-empty unknown-projection snapshots', async (t) => { for (const variant of ['conflict', 'non-idle', 'event-seq', 'messages', 'target']) await t.test(variant, () => withClient(async (client) => { await assert.rejects(client.createSession({ workspace: '/repo' }), { code: 'ZCODE_OUTPUT_INVALID' }); @@ -907,6 +940,43 @@ test('broker validates snapshot workspace and every current-session ID before co }); }); +test('broker rejects invalid empty-create snapshots before ownership persistence or list exposure', async (t) => { + const variants = { + conflict: (snapshot) => { snapshot.projection.sessionId = 'other-session'; }, + 'non-idle': (snapshot) => { snapshot.projection.status = 'running'; }, + 'event-seq': (snapshot) => { snapshot.runtime.eventSeq = 1; }, + messages: (snapshot, sessionId) => { + const messageId = 'non-empty-message'; + snapshot.messages = [{ info: { messageId, sessionId, role: 'user', time: { created: 1 }, agent: 'build', model: snapshot.settings.model.current }, parts: [{ partId: 'non-empty-part', sessionId, messageId, type: 'text', text: 'not empty' }] }]; + }, + target: (snapshot, sessionId) => { snapshot.projection.target = { sessionId, targetId: 'non-empty-target', objective: 'not empty', summaryTitle: null, status: 'active', tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0, createdAt: 1, updatedAt: 1 }; }, + }; + for (const [variant, mutate] of Object.entries(variants)) await t.test(variant, async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-empty-create-state-')); + const endpoint = join(directory, 'broker.sock'); const ownershipPath = join(directory, 'session-owners.json'); const sessionId = `invalid-empty-${variant}`; const ownerId = `invalid-empty-${variant}-owner`; + const writes = []; const socket = { writable: true, destroyed: false, zcodeWriter: { write: (line) => writes.push(JSON.parse(line)) }, destroy() {} }; + const broker = newTestBroker({ endpoint, ownershipPath, brokerToken: '1'.repeat(64), workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture } }); + broker.authenticated.add(socket); broker.socketOwnerIds.set(socket, ownerId); + const invalid = brokerCreateSnapshot(sessionId, directory); invalid.projection.sessionId = 'unknown'; mutate(invalid, sessionId); + let persistCalls = 0; const persistOwnership = broker.persistOwnership.bind(broker); + broker.persistOwnership = async (...args) => { persistCalls += 1; return persistOwnership(...args); }; + const rejectedProtocol = { request: async () => invalid, close: async () => {} }; broker.protocol = rejectedProtocol; + try { + await broker.handleLocal(socket, JSON.stringify({ id: 90, method: 'session/create', params: brokerCreateParams(directory) })); + assert.equal(writes.find((frame) => frame.id === 90)?.error?.data?.pluginError?.code, 'ZCODE_OUTPUT_INVALID'); + assert.equal(persistCalls, 0, 'invalid create must be rejected before persistOwnership'); + assert.equal(broker.sessionOwners.has(sessionId), false); + const durable = await readFile(ownershipPath, 'utf8').then((value) => JSON.parse(value).sessions, (error) => error.code === 'ENOENT' ? {} : Promise.reject(error)); + assert.equal(Object.hasOwn(durable, sessionId), false); + assert.equal(broker.protocol, null, 'invalid create must clear its protocol generation'); + for (let index = 0; index < 20 && broker.retiredProtocolGeneration; index += 1) await new Promise((resolvePromise) => setImmediate(resolvePromise)); + const replacement = { request: async () => ({ sessions: [invalid.session] }), close: async () => {} }; broker.protocol = replacement; + await broker.handleLocal(socket, JSON.stringify({ id: 91, method: 'session/list', params: {} })); + assert.deepEqual(writes.find((frame) => frame.id === 91)?.result, { sessions: [] }); + } finally { await broker.close(); await rm(directory, { recursive: true, force: true }); } + }); +}); + test('invented and malformed nested 0.16.1 response fields are rejected', async (t) => { for (const variant of ['invented-session-kind', 'invented-subagent-kind', 'bad-protocol', 'missing-model-label', 'string-message-model', 'bad-goal-stats', 'bad-permission-origin', 'bad-runtime-cache', 'bad-timeline-trigger', 'bad-provider-options']) await t.test(variant, () => withClient(async (client) => { await assert.rejects(client.createSession({ workspace: '/repo' }), { code: 'ZCODE_OUTPUT_INVALID' }); }, { FAKE_ZCODE_BAD_SNAPSHOT: variant }, process.platform === 'win32' ? { requestTimeoutMs: 2_000, completionTimeoutMs: 2_000 } : {})); });