diff --git a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts index b5bde91bf1..6dc28d7cf2 100644 --- a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { decodeWorkHubCoordinationActFromTurnInput, decodeWorkHubCoordinationSelectAndDelegateInput } from '@maka/runtime-host/protocol'; import { workHubTasksSchema } from '../../shared/workhub-tool-schema.js'; import { createWorkHubRuntime } from '../workhub-runtime.js'; @@ -60,6 +61,38 @@ test('task delegation binds the tool action to the Host turn and trusted creatio assert.deepEqual(f.changes, [[scope, 'created', 'target']]); }); +test('nested tool calls produce stable task identities accepted by both Host action paths', async () => { + const f = fixture(); + const toolCallId = `${'p'.repeat(128)}:nested:00000000-0000-4000-8000-000000000001`; + const actionIds: string[] = []; + const result = { disposition: 'create_new' as const, targetSessionId: 'target', targetTurnId: 'target-turn' }; + f.client.actWorkHubCoordinationFromTurn = async (input) => { + actionIds.push(decodeWorkHubCoordinationActFromTurnInput(input).actionId); + return result; + }; + f.client.selectAndDelegateWorkHubTarget = async (input) => { + actionIds.push(decodeWorkHubCoordinationSelectAndDelegateInput(input).actionId); + return { kind: 'delegated', result }; + }; + + const create = { operation: 'create_new' as const, title: 'Work', text: 'Do work' }; + const created = await f.runtime.actTasks(scope, 'turn', toolCallId, create); + const retried = await f.runtime.actTasks(scope, 'turn', toolCallId, create); + const selected = await f.runtime.actTasks(scope, 'turn', toolCallId, { + operation: 'select_and_delegate', candidateSetId: `sha256:${'a'.repeat(64)}`, + candidateRefs: ['candidate'], text: 'Do work', + }); + for (const outcome of [created, retried, selected]) { + assert.ok('actionId' in outcome); + assert.equal(outcome.actionId, actionIds[0]); + } + assert.deepEqual(actionIds, [actionIds[0], actionIds[0], actionIds[0]]); + const next = await f.runtime.actTasks(scope, 'turn', toolCallId.replace(/1$/, '2'), create); + assert.ok('actionId' in next); + assert.equal(next.actionId, actionIds[3]); + assert.notEqual(next.actionId, actionIds[0]); +}); + test('linked task operations remain operations at the Host protocol boundary', async () => { const f = fixture(); await f.runtime.actTasks(scope, 'turn', 'correct-action', { diff --git a/apps/desktop/src/main/workhub-runtime.ts b/apps/desktop/src/main/workhub-runtime.ts index 22a4c185cf..3f318ea8e0 100644 --- a/apps/desktop/src/main/workhub-runtime.ts +++ b/apps/desktop/src/main/workhub-runtime.ts @@ -18,6 +18,7 @@ */ import { WORKHUB_COORDINATION_SESSION_ID, type WorkHubCreateDefaults } from '@maka/core/session'; +import { clientCapabilityEntityId } from '@maka/runtime-host/client-capability-entity-id'; import type { WorkHubCoordinationProposal, WorkspaceTarget } from '@maka/runtime-host/protocol'; import { desktopSessionKey, type DesktopTargetScope } from '../shared/runtime-host-identity.js'; import type { WorkHubTasksInput } from '../shared/workhub-tool-schema.js'; @@ -55,10 +56,12 @@ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { const turn = await queryTurn(client, turnId); if (isLive(turn)) await client.stopTurn({ sessionId: turn.sessionId, turnId: turn.turnId, runId: turn.runId }); }, - async actTasks(scope: DesktopTargetScope, turnId: string, actionId: string, input: WorkHubTasksInput) { + async actTasks(scope: DesktopTargetScope, turnId: string, toolCallId: string, input: WorkHubTasksInput) { requireCurrent(scope); const client = deps.client(scope); if (input.operation === 'candidates') return client.listWorkHubCoordinationCandidates(); + // WorkHub persists actions as entities; capability tool-call IDs are opaque. + const actionId = clientCapabilityEntityId(toolCallId); if (input.operation === 'select_and_delegate') { const outcome = await client.selectAndDelegateWorkHubTarget({ turnId, actionId, candidateSetId: input.candidateSetId, candidateRefs: input.candidateRefs, delegationText: input.text }); diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index f9a5a55381..332829d622 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -31,6 +31,44 @@ import { } from '../protocol/index.js'; describe('Client Capability protocol', () => { + test('preserves opaque tool-call IDs while retaining identity bounds', () => { + const frame = { + kind: 'client.capability.call', + invocationId: 'invocation', + registrationId: 'registration', + offerId: 'offer', + serverId: 'server', + toolName: 'tool', + arguments: {}, + sessionId: 'session', + turnId: 'turn', + }; + for (const toolCallId of ['call:outer:nested:inner', 'provider/call.1+part', 'x'.repeat(256)]) { + assert.deepEqual(decodeHostFrame({ ...frame, toolCallId }), { ...frame, toolCallId }); + } + for (const toolCallId of [ + undefined, + null, + 1, + '', + 'x'.repeat(257), + ' leading', + 'trailing ', + 'a\nb', + 'a\u0000b', + 'a\u007fb', + ]) { + assert.throws(() => decodeHostFrame({ ...frame, toolCallId }), RuntimeHostProtocolError); + } + for (const field of ['invocationId', 'registrationId', 'offerId', 'sessionId', 'turnId']) { + assert.throws( + () => + decodeHostFrame({ ...frame, toolCallId: 'call:nested:inner', [field]: 'entity:invalid' }), + RuntimeHostProtocolError, + ); + } + }); + test('decodes open-world registration and reverse-call lifecycle frames', () => { assert.deepEqual( decodeClientFrame({ diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index b99987ebe1..737a9390d9 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -126,6 +126,9 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' await client.status(); const largeValue = 'x'.repeat(100_000); + // Code Mode appends its nested identity to a provider-owned call ID. + // This crosses both the entity-ID alphabet and its 128-character limit. + const toolCallId = `${'p'.repeat(128)}:nested:00000000-0000-4000-8000-000000000001`; let providerCloseCalls = 0; const provider: ClientCapabilityProvider = { offers: () => [ @@ -161,6 +164,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' }, ], call: async (frame, { accept }) => { + assert.equal(frame.toolCallId, toolCallId); if (frame.toolName === 'reject_unknown') { throw new Error('Provider rejected before acceptance'); } @@ -201,7 +205,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' sessionId: 'session-uds', turnId: 'turn-uds', cwd: root, - toolCallId: 'tool-call-uds', + toolCallId, abortSignal: new AbortController().signal, emitOutput: () => undefined, }; @@ -238,6 +242,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' assert.deepEqual(result, { content: [{ type: 'text', text: `from-uds:${largeValue}` }], }); + await client.status(); await assert.rejects( async () => rejectedTool.impl({}, toolContext), (error: unknown) => @@ -269,6 +274,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' await client.replaceClientCapabilities({ offers: provider.offers, call: async (frame, { accept }) => { + assert.equal(frame.toolCallId, toolCallId); await accept({ kind: 'none' }); return { content: [{ type: 'text', text: `reconnected:${String(frame.arguments.prefix)}` }], diff --git a/packages/runtime-host/src/protocol/agent-graph.ts b/packages/runtime-host/src/protocol/agent-graph.ts index 02c9842c00..706115ff76 100644 --- a/packages/runtime-host/src/protocol/agent-graph.ts +++ b/packages/runtime-host/src/protocol/agent-graph.ts @@ -22,6 +22,7 @@ import { requireEncodedByteLimit, requireEntityId, requireExactRecord, + requireOpaqueIdentity, requireShapedRecord, requireUtf8String, } from './codec.js'; @@ -1348,19 +1349,6 @@ function requireBoolean(value: unknown, label: string): boolean { return value; } -function requireOpaqueIdentity(value: unknown, label: string): string { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 256 || - value.trim() !== value || - /[\u0000-\u001f\u007f]/.test(value) - ) { - throw invalidProtocolFrame(`Invalid ${label}`); - } - return value; -} - function requireFingerprint(value: unknown, label: string): `sha256:${string}` { if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) { throw invalidProtocolFrame(`Invalid ${label}`); diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index f50025487c..643c1b4e9c 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -29,6 +29,7 @@ import { requireCount, requireEntityId, requireExactRecord, + requireOpaqueIdentity, requireRecord, requireString, } from './codec.js'; @@ -580,7 +581,7 @@ export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilit arguments: argumentsValue, sessionId: requireEntityId(frame.sessionId, 'sessionId'), turnId: requireEntityId(frame.turnId, 'turnId'), - toolCallId: requireEntityId(frame.toolCallId, 'toolCallId'), + toolCallId: requireOpaqueIdentity(frame.toolCallId, 'toolCallId'), ...(frame.cwd === undefined ? {} : { cwd: requireString(frame.cwd, 'cwd', 4_096) }), }; } diff --git a/packages/runtime-host/src/protocol/codec.ts b/packages/runtime-host/src/protocol/codec.ts index b1288ea309..62d00ec342 100644 --- a/packages/runtime-host/src/protocol/codec.ts +++ b/packages/runtime-host/src/protocol/codec.ts @@ -84,6 +84,20 @@ export function requireEntityId(value: unknown, label: string): string { return id; } +/** Correlation identities are preserved verbatim, not interpreted as entity paths. */ +export function requireOpaqueIdentity(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value.trim() !== value || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw invalidProtocolFrame(`Invalid ${label}`); + } + return value; +} + export function requireCount(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { throw invalidProtocolFrame(`Invalid ${label}`); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 10235f989e..e3a32337e2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 155 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 156 as const; // 154: External Session import results distinguish committed Sessions from typed source limits. // 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses.