Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', {
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/workhub-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)}` }],
Expand Down
14 changes: 1 addition & 13 deletions packages/runtime-host/src/protocol/agent-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireOpaqueIdentity,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
Expand Down Expand Up @@ -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}`);
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-host/src/protocol/client-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
requireCount,
requireEntityId,
requireExactRecord,
requireOpaqueIdentity,
requireRecord,
requireString,
} from './codec.js';
Expand Down Expand Up @@ -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) }),
};
}
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-host/src/protocol/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down