Skip to content

Commit a52907e

Browse files
authored
Merge pull request #21 from vitry/fix/rescue-empty-session-create
fix: accept bounded empty create snapshots
2 parents 600def8 + 3401dc9 commit a52907e

5 files changed

Lines changed: 129 additions & 12 deletions

File tree

marketplace/plugins/zcode/scripts/lib/zcode-client.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { readdir, realpath } from 'node:fs/promises';
55
import { PluginError } from './errors.mjs';
66
import { isBoundedPublicIdentifier, isSafeIdentifier } from './identifier.mjs';
77
import { closeProtocolUntil, connectZCodeBroker, MAX_DRAIN_TIMEOUT_MS, spawnZCodeProtocol } from './zcode-protocol.mjs';
8-
import { validSessionInfo, validSetupAuthProbeSnapshot, validSnapshot as snapshotValid } from './zcode-schema.mjs';
8+
import { validCreateSnapshot, validSessionInfo, validSetupAuthProbeSnapshot, validSnapshot as snapshotValid } from './zcode-schema.mjs';
99
import { brokerEndpointFor, brokerIdentityNameForWireOptions, ensureZCodeBroker, inspectBrokerIdentity, MAX_BROKER_IDLE_TIMEOUT_MS, MIN_BROKER_IDLE_TIMEOUT_MS, prioritizeBrokerOwnership } from '../zcode-broker.mjs';
1010
import { resolveWorkspaceStorage } from './workspace.mjs';
1111

@@ -22,7 +22,7 @@ export class ZCodeClient {
2222

2323
/** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */
2424
async createSession(input) {
25-
return this.createSessionValidated(input, snapshotValid);
25+
return this.createSessionValidated(input, validCreateSnapshot);
2626
}
2727

2828
/** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input Setup-only compatibility probe; formal runtime callers must use createSession(). */

marketplace/plugins/zcode/scripts/lib/zcode-schema.mjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,18 +162,36 @@ export function validSnapshot(/** @type {any} */ value, /** @type {string} */ se
162162
}
163163

164164
/**
165-
* Setup-only compatibility validation for ZCode 0.16.1's empty projection.
166-
* The normal validSnapshot relation remains strict for all runtime paths.
165+
* Compatibility validation for ZCode 0.16.1's initial empty projection.
166+
* The normal validSnapshot relation remains strict for reads and updates.
167167
* @param {any} value @param {string} sessionId @param {string} workspacePath
168168
*/
169-
export function validSetupAuthProbeSnapshot(value, sessionId, workspacePath) {
169+
function validEmptyCreateSnapshot(value, sessionId, workspacePath) {
170170
return validSnapshotEnvelope(value)
171171
&& text(sessionId) && value.session.sessionId === sessionId
172172
&& value.session.workspace.workspacePath === workspacePath && value.session.workspace.workspaceKey === workspacePath
173173
&& value.session.status === 'idle'
174174
&& value.projection.sessionId === 'unknown' && value.projection.status === 'idle'
175175
&& (value.session.target === undefined || value.session.target === null)
176176
&& (value.projection.target === undefined || value.projection.target === null)
177+
&& value.projection.currentTurnId === undefined
178+
&& value.projection.turnCount === 0 && value.projection.totalTokenCount === 0 && value.projection.contextUsed === 0
177179
&& value.projection.pendingPermissions.length === 0 && value.projection.activeToolCalls.length === 0 && value.projection.backgroundJobs.length === 0
178-
&& value.runtime.eventSeq === 0 && value.runtime.pendingRequestIds.length === 0 && value.messages.length === 0;
180+
&& value.projection.lastError === undefined
181+
&& value.runtime.eventSeq === 0 && value.runtime.stateRevision === 0
182+
&& value.runtime.activeTurnId === undefined && value.runtime.activeTurnKind === undefined
183+
&& value.runtime.pendingRequestIds.length === 0
184+
&& (value.runtime.apiRetry === undefined || value.runtime.apiRetry === null)
185+
&& value.runtime.contextUsage === undefined
186+
&& (value.runtime.goalVerifications === undefined || value.runtime.goalVerifications.length === 0)
187+
&& (value.runtime.goalVerificationTimeline === undefined || value.runtime.goalVerificationTimeline.length === 0)
188+
&& value.messages.length === 0;
189+
}
190+
191+
export function validCreateSnapshot(/** @type {any} */ value, /** @type {string} */ sessionId, /** @type {string} */ workspacePath) {
192+
return validSnapshot(value, sessionId, workspacePath) || validEmptyCreateSnapshot(value, sessionId, workspacePath);
193+
}
194+
195+
export function validSetupAuthProbeSnapshot(/** @type {any} */ value, /** @type {string} */ sessionId, /** @type {string} */ workspacePath) {
196+
return validEmptyCreateSnapshot(value, sessionId, workspacePath);
179197
}

scripts/lib/zcode-client.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { readdir, realpath } from 'node:fs/promises';
55
import { PluginError } from './errors.mjs';
66
import { isBoundedPublicIdentifier, isSafeIdentifier } from './identifier.mjs';
77
import { closeProtocolUntil, connectZCodeBroker, MAX_DRAIN_TIMEOUT_MS, spawnZCodeProtocol } from './zcode-protocol.mjs';
8-
import { validSessionInfo, validSetupAuthProbeSnapshot, validSnapshot as snapshotValid } from './zcode-schema.mjs';
8+
import { validCreateSnapshot, validSessionInfo, validSetupAuthProbeSnapshot, validSnapshot as snapshotValid } from './zcode-schema.mjs';
99
import { brokerEndpointFor, brokerIdentityNameForWireOptions, ensureZCodeBroker, inspectBrokerIdentity, MAX_BROKER_IDLE_TIMEOUT_MS, MIN_BROKER_IDLE_TIMEOUT_MS, prioritizeBrokerOwnership } from '../zcode-broker.mjs';
1010
import { resolveWorkspaceStorage } from './workspace.mjs';
1111

@@ -22,7 +22,7 @@ export class ZCodeClient {
2222

2323
/** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */
2424
async createSession(input) {
25-
return this.createSessionValidated(input, snapshotValid);
25+
return this.createSessionValidated(input, validCreateSnapshot);
2626
}
2727

2828
/** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input Setup-only compatibility probe; formal runtime callers must use createSession(). */

scripts/lib/zcode-schema.mjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,18 +162,36 @@ export function validSnapshot(/** @type {any} */ value, /** @type {string} */ se
162162
}
163163

164164
/**
165-
* Setup-only compatibility validation for ZCode 0.16.1's empty projection.
166-
* The normal validSnapshot relation remains strict for all runtime paths.
165+
* Compatibility validation for ZCode 0.16.1's initial empty projection.
166+
* The normal validSnapshot relation remains strict for reads and updates.
167167
* @param {any} value @param {string} sessionId @param {string} workspacePath
168168
*/
169-
export function validSetupAuthProbeSnapshot(value, sessionId, workspacePath) {
169+
function validEmptyCreateSnapshot(value, sessionId, workspacePath) {
170170
return validSnapshotEnvelope(value)
171171
&& text(sessionId) && value.session.sessionId === sessionId
172172
&& value.session.workspace.workspacePath === workspacePath && value.session.workspace.workspaceKey === workspacePath
173173
&& value.session.status === 'idle'
174174
&& value.projection.sessionId === 'unknown' && value.projection.status === 'idle'
175175
&& (value.session.target === undefined || value.session.target === null)
176176
&& (value.projection.target === undefined || value.projection.target === null)
177+
&& value.projection.currentTurnId === undefined
178+
&& value.projection.turnCount === 0 && value.projection.totalTokenCount === 0 && value.projection.contextUsed === 0
177179
&& value.projection.pendingPermissions.length === 0 && value.projection.activeToolCalls.length === 0 && value.projection.backgroundJobs.length === 0
178-
&& value.runtime.eventSeq === 0 && value.runtime.pendingRequestIds.length === 0 && value.messages.length === 0;
180+
&& value.projection.lastError === undefined
181+
&& value.runtime.eventSeq === 0 && value.runtime.stateRevision === 0
182+
&& value.runtime.activeTurnId === undefined && value.runtime.activeTurnKind === undefined
183+
&& value.runtime.pendingRequestIds.length === 0
184+
&& (value.runtime.apiRetry === undefined || value.runtime.apiRetry === null)
185+
&& value.runtime.contextUsage === undefined
186+
&& (value.runtime.goalVerifications === undefined || value.runtime.goalVerifications.length === 0)
187+
&& (value.runtime.goalVerificationTimeline === undefined || value.runtime.goalVerificationTimeline.length === 0)
188+
&& value.messages.length === 0;
189+
}
190+
191+
export function validCreateSnapshot(/** @type {any} */ value, /** @type {string} */ sessionId, /** @type {string} */ workspacePath) {
192+
return validSnapshot(value, sessionId, workspacePath) || validEmptyCreateSnapshot(value, sessionId, workspacePath);
193+
}
194+
195+
export function validSetupAuthProbeSnapshot(/** @type {any} */ value, /** @type {string} */ sessionId, /** @type {string} */ workspacePath) {
196+
return validEmptyCreateSnapshot(value, sessionId, workspacePath);
179197
}

tests/zcode-client.test.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { brokerEndpointFor, brokerIdentityNameForWireOptions, ensureZCodeBroker,
1414
import { atomicWriteJson, withFileLock } from '../scripts/lib/fs.mjs';
1515
import { PluginError } from '../scripts/lib/errors.mjs';
1616
import { resolveWorkspaceStorage } from '../scripts/lib/workspace.mjs';
17+
import { validCreateSnapshot, validSetupAuthProbeSnapshot, validSnapshot } from '../scripts/lib/zcode-schema.mjs';
1718

1819
const fixture = fileURLToPath(new URL('./fixtures/fake-zcode-cli.mjs', import.meta.url));
1920
const brokerStartupFault = fileURLToPath(new URL('./fixtures/broker-startup-fault.cjs', import.meta.url));
@@ -236,6 +237,86 @@ test('typed operations use real 0.16.1 method and parameter shapes', async () =>
236237
});
237238
});
238239

240+
test('ordinary session/create accepts the bounded 0.16.1 initial empty-session snapshot', async () => {
241+
await withClient(async (client) => {
242+
const created = await client.createSession({ workspace: '/repo' });
243+
assert.equal(created.session.sessionId, 'session-1');
244+
assert.equal(created.projection.sessionId, 'unknown');
245+
assert.deepEqual(created.messages, []);
246+
}, { FAKE_ZCODE_EMPTY_SESSION: '1' });
247+
});
248+
249+
test('ordinary session/create rejects conflicting or non-empty unknown-projection snapshots', async (t) => {
250+
for (const variant of ['conflict', 'non-idle', 'event-seq', 'messages', 'target']) await t.test(variant, () => withClient(async (client) => {
251+
await assert.rejects(client.createSession({ workspace: '/repo' }), { code: 'ZCODE_OUTPUT_INVALID' });
252+
}, { FAKE_ZCODE_EMPTY_SESSION: '1', FAKE_ZCODE_EMPTY_SESSION_VARIANT: variant }));
253+
});
254+
255+
test('ordinary empty session/create retains explicit session ID binding', async () => {
256+
await withClient(async (client) => {
257+
await assert.rejects(client.createSession({ workspace: '/repo', sessionId: 'requested-session' }), { code: 'ZCODE_OUTPUT_INVALID' });
258+
}, { FAKE_ZCODE_EMPTY_SESSION: '1', FAKE_ZCODE_SESSION_ID: 'different-session' });
259+
});
260+
261+
test('the unknown-projection exception remains confined to session/create', async (t) => {
262+
for (const method of ['session/read', 'session/resume', 'session/setModel', 'session/setThoughtLevel']) await t.test(method, () => withClient(async (client) => {
263+
const sessionId = (await client.createSession({ workspace: '/repo' })).session.sessionId;
264+
const operation = method === 'session/read' ? () => client.readSession(sessionId)
265+
: method === 'session/resume' ? () => client.resumeSession(sessionId)
266+
: method === 'session/setModel' ? () => client.setModel(sessionId, { providerId: 'fake2', modelId: 'other' })
267+
: () => client.setThoughtLevel(sessionId, 'high');
268+
await assert.rejects(operation(), { code: 'ZCODE_OUTPUT_INVALID' });
269+
}, { FAKE_ZCODE_EMPTY_SESSION: '1' }));
270+
});
271+
272+
test('the empty-create validator rejects every remaining non-empty or conflicting relation', async () => {
273+
await withClient(async (client) => {
274+
const empty = await client.createSession({ workspace: '/repo' });
275+
const sessionId = empty.session.sessionId; const workspace = resolve('/repo');
276+
assert.equal(validSnapshot(empty, sessionId, workspace), false, 'fixture must enter the empty-create branch');
277+
const target = { sessionId, targetId: 'target-1', objective: 'not empty', summaryTitle: null, status: 'active', tokenBudget: null, tokensUsed: 0, timeUsedSeconds: 0, createdAt: 1, updatedAt: 1 };
278+
const permission = { requestId: 'request-1', toolCallId: 'tool-1', toolName: 'write', reason: 'not empty', riskLevel: 'low', options: [{ optionId: 'allow', kind: 'allow', name: 'Allow', response: { decision: 'allow' } }], requestedAt: 1 };
279+
const verification = { passed: false, reason: 'not empty' };
280+
const cases = [
281+
['non-idle session status', (value) => { value.session.status = 'running'; }, true],
282+
['non-null session target', (value) => { value.session.target = target; }, true],
283+
['current projection turn', (value) => { value.projection.currentTurnId = 'turn-1'; }, true],
284+
['nonzero projection turn count', (value) => { value.projection.turnCount = 1; }, true],
285+
['nonzero projection token count', (value) => { value.projection.totalTokenCount = 1; }, true],
286+
['nonzero projection context use', (value) => { value.projection.contextUsed = 1; }, true],
287+
['pending projection permission', (value) => { value.projection.pendingPermissions = [permission]; }, true],
288+
['active projection tool call', (value) => { value.projection.activeToolCalls = [{ toolCallId: 'tool-1', toolName: 'write', status: 'pending' }]; }, true],
289+
['background projection job', (value) => { value.projection.backgroundJobs = [{}]; }, true],
290+
['projection error', (value) => { value.projection.lastError = { type: 'runtime', message: 'not empty' }; }, true],
291+
['nonzero runtime revision', (value) => { value.runtime.stateRevision = 1; }, true],
292+
['active runtime turn ID', (value) => { value.runtime.activeTurnId = 'turn-1'; }, true],
293+
['active runtime turn kind', (value) => { value.runtime.activeTurnKind = 'regular'; }, true],
294+
['pending runtime request', (value) => { value.runtime.pendingRequestIds = ['request-1']; }, true],
295+
['runtime API retry', (value) => { value.runtime.apiRetry = { kind: 'api_retry', attempt: 1, maxRetries: 2, retryDelayMs: 100, errorStatus: null, error: 'retrying' }; }, true],
296+
['runtime context usage', (value) => { value.runtime.contextUsage = { used: 1, size: 128000 }; }, true],
297+
['runtime goal verification', (value) => { value.runtime.goalVerifications = [verification]; }, true],
298+
['runtime goal verification timeline', (value) => { value.runtime.goalVerificationTimeline = [{ version: 1, kind: 'synthetic', type: 'goal_verification', display: 'separator', targetId: 'target-1', verificationId: 'verification-1', status: 'started' }]; }, true],
299+
['wrong workspace path', (value) => { value.session.workspace.workspacePath = '/wrong-workspace'; }, false],
300+
['wrong workspace key', (value) => { value.session.workspace.workspaceKey = '/wrong-workspace'; }, false],
301+
];
302+
for (const [name, mutate, strictCompatible] of cases) {
303+
const candidate = structuredClone(empty); mutate(candidate);
304+
assert.equal(validSnapshot(candidate, sessionId, workspace), false, `${name}: strict branch must remain unavailable`);
305+
assert.equal(validCreateSnapshot(candidate, sessionId, workspace), false, `${name}: empty-create branch must reject the mutation`);
306+
assert.equal(validSetupAuthProbeSnapshot(candidate, sessionId, workspace), false, `${name}: setup probe must reject the mutation`);
307+
if (strictCompatible) {
308+
candidate.projection.sessionId = sessionId;
309+
assert.equal(validSnapshot(candidate, sessionId, workspace), true, `${name}: mutation must otherwise retain a valid snapshot envelope`);
310+
}
311+
}
312+
const explicitEmpty = structuredClone(empty);
313+
explicitEmpty.session.target = null; explicitEmpty.projection.target = null; explicitEmpty.runtime.apiRetry = null;
314+
explicitEmpty.runtime.goalVerifications = []; explicitEmpty.runtime.goalVerificationTimeline = [];
315+
assert.equal(validCreateSnapshot(explicitEmpty, sessionId, workspace), true, 'explicit null and empty activity state must remain fresh');
316+
assert.equal(validSetupAuthProbeSnapshot(explicitEmpty, sessionId, workspace), true, 'setup probe must accept explicit null and empty activity state');
317+
}, { FAKE_ZCODE_EMPTY_SESSION: '1' });
318+
});
319+
239320
test('session/create answers runtime preference requests with the exact string ID', async () => {
240321
await withClient(async (client, record) => {
241322
const created = await client.createSession({ workspace: '/repo' });

0 commit comments

Comments
 (0)