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
209 changes: 208 additions & 1 deletion apps/desktop/src/main/__tests__/workhub-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol';
import {
createWorkHubController as createGatedWorkHubController,
WORKHUB_ROUTING_STRATEGY_ID,
WorkHubCoordinationFailure,
type WorkHubSessionFacts,
type WorkHubSessionPort,
type WorkHubCoordinationTurn,
Expand All @@ -32,7 +33,6 @@ import {
createWorkHubRoutePolicy,
workHubNewSessionName,
} from '../../renderer/workhub-route-policy.js';
import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js';

const appShellUrl = [
new URL('../../renderer/app-shell.tsx', import.meta.url),
Expand Down Expand Up @@ -193,6 +193,14 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) {
targetSessionId: input.proposal.expects.targetSessionId,
};
}
if (input.proposal.disposition === 'resume_work') {
return {
disposition: 'resume_work',
outcome: 'resume_started',
targetSessionId: input.proposal.expects.targetSessionId,
targetTurnId: 'resumed-turn',
};
}
const target = candidateByRef.get(input.proposal.candidateRef);
if (!target) throw new Error('unknown test candidate');
const admitted = await sessions.submit(target.target, input.userText, input.actionId);
Expand Down Expand Up @@ -408,6 +416,205 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou
await handle.close();
});

test('a named resume submits and reports what the Host did', async () => {
const sessions = port([session('payments', { sessionName: 'Payments' })]);
const actions: WorkHubCoordinationActInput[] = [];
const controller = createGatedWorkHubController({
sessions,
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'e'.repeat(64)}`,
candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }],
}),
act: async (input) => {
actions.push(input);
return {
disposition: 'resume_work',
outcome: 'resume_started',
targetSessionId: 'payments',
targetTurnId: 'resumed-turn',
};
},
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

const result = await controller.submit({ requestId: 'resume-1', text: 'Resume Payments' });

assert.deepEqual(result, {
kind: 'resume',
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'resume-1',
target: { sessionId: 'payments' },
outcome: 'resume_started',
});
// The proposal names the Session and carries no confirmation: resume ends
// nothing, so it needs no authority a delegation did not already grant.
assert.deepEqual(actions, [{
actionId: 'resume-1',
userText: 'Resume Payments',
proposal: { disposition: 'resume_work', resumesActionId: 'source-action', expects: { targetSessionId: 'payments' } },
}]);
await handle.close();
});

test('an anaphoric resume asks for a named work item', async () => {
const controller = createGatedWorkHubController({
sessions: port([session('payments', { sessionName: 'Payments' })]),
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => assert.fail('resume clarification must not read route candidates'),
act: async () => assert.fail('anaphoric resume must not reach the Action Gate'),
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

assert.deepEqual(await controller.submit({ requestId: 'resume-it', text: 'Resume it' }), {
kind: 'clarification',
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'resume-it',
text: 'Resume it',
options: [],
reason: 'resume_target_required',
});
await handle.close();
});

test('a resume the Host will not admit becomes its clarification', async () => {
const sessions = port([session('payments', { sessionName: 'Payments' })]);
const controller = createGatedWorkHubController({
sessions,
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'e'.repeat(64)}`,
candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }],
}),
act: async () => {
throw new WorkHubCoordinationFailure(
'operation_conflict',
'WorkHub has no active durable delegation to resume on that Session',
);
},
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

assert.deepEqual(await controller.submit({ requestId: 'resume-2', text: 'Resume Payments' }), {
kind: 'clarification',
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'resume-2',
text: 'Resume Payments',
options: [],
reason: 'resume_target_unavailable',
});
await handle.close();
});

test('a resume identity conflict is not mislabeled as a missing target', async () => {
const conflict = new WorkHubCoordinationFailure(
'operation_conflict',
'WorkHub action identity already owns a different operation',
);
const controller = createGatedWorkHubController({
sessions: port([session('payments', { sessionName: 'Payments' })]),
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'e'.repeat(64)}`,
candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }],
}),
act: async () => {
throw conflict;
},
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

await assert.rejects(
controller.submit({ requestId: 'resume-conflict', text: 'Resume Payments' }),
(error) => error === conflict,
);
await handle.close();
});

test('a Runtime Host without safe-boundary resume explains why it cannot resume', async () => {
const controller = createGatedWorkHubController({
sessions: port([session('payments', { sessionName: 'Payments' })]),
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'e'.repeat(64)}`,
candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }],
}),
act: async () => {
throw new WorkHubCoordinationFailure(
'operation_unavailable',
'Safe-boundary resume is disabled for this Runtime Host',
);
},
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

assert.deepEqual(await controller.submit({ requestId: 'resume-disabled', text: 'Resume Payments' }), {
kind: 'clarification',
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'resume-disabled',
text: 'Resume Payments',
options: [],
reason: 'resume_operation_unavailable',
});
await handle.close();
});

test('a recovering Runtime Host tells the user to retry resume', async () => {
const controller = createGatedWorkHubController({
sessions: port([session('payments', { sessionName: 'Payments' })]),
coordination: {
open: async (handler) => {
handler([]);
return { close: async () => undefined };
},
record: async (input) => ({ turnId: input.turnId }),
candidates: async () => ({
candidateSetId: `sha256:${'e'.repeat(64)}`,
candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }],
}),
act: async () => {
throw new WorkHubCoordinationFailure('host_not_ready', 'Runtime Host is recovering');
},
},
});
const handle = await controller.openConversation(() => undefined, () => undefined);

const result = await controller.submit({ requestId: 'resume-recovering', text: 'Resume Payments' });
assert.equal(result.kind, 'clarification');
if (result.kind === 'clarification') assert.equal(result.reason, 'resume_host_recovering');
await handle.close();
});

test('a named stop reports the Gate refusal instead of judging the target itself', async () => {
// The renderer no longer decides whether a Session can be stopped, so it
// submits and lets the Gate answer. Its refusal is the clarification, which
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,61 @@ test('surface replaces a submitted placeholder with its durable assignment state
});
});

test('surface replaces resume feedback with the ordinary coordination acknowledgement', () => {
const local = [
{
requestId: 'stop-action',
text: 'Stop Payments',
state: 'settled' as const,
outcome: {
kind: 'stop' as const,
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'stop-action',
target: { sessionId: 'payments' },
outcome: 'stop_delivered' as const,
},
},
{
requestId: 'resume-action',
text: 'Resume Payments',
state: 'settled' as const,
outcome: {
kind: 'resume' as const,
strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: 'resume-action',
target: { sessionId: 'payments' },
outcome: 'resume_started' as const,
},
},
];
const durable: WorkHubCoordinationTurn[] = [
{
messageId: 'stop-record',
turnId: 'stop-action',
text: 'Stop Payments',
state: 'completed',
stop: {
targetSessionId: 'payments',
targetSessionName: 'Payments',
outcome: 'stop_delivered',
},
updatedAt: 10,
},
{
messageId: 'resume-record',
turnId: 'resume-action',
text: 'Resume Payments',
state: 'completed',
updatedAt: 20,
},
];

assert.deepEqual(visibleWorkHubConversation(durable, local), {
coordination: durable,
local: [],
});
});

test('surface keeps clarification and successful routing in WorkHub', async () => {
const submissions: WorkHubSubmitInput[] = [];
const controller: WorkHubController = {
Expand Down Expand Up @@ -665,6 +720,14 @@ test('real Session projection creates new guide topics and preserves origin ambi
targetSessionId: input.proposal.expects.targetSessionId,
};
}
if (input.proposal.disposition === 'resume_work') {
return {
disposition: 'resume_work',
outcome: 'resume_started',
targetSessionId: input.proposal.expects.targetSessionId,
targetTurnId: 'resumed-turn',
};
}
const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, '');
const admitted = await send(targetSessionId, {
type: 'send',
Expand Down Expand Up @@ -775,6 +838,28 @@ test('successful delegated submission needs no renderer summary write', async ()
assert.equal(records, 0);
});

test('resume records ordinary conversation text without persisting execution fields', async () => {
const records: unknown[] = [];
const controller = fakeController({
submit: async (input) => ({
kind: 'resume', strategyId: WORKHUB_ROUTING_STRATEGY_ID,
requestId: input.requestId, target: { sessionId: 'payments' }, outcome: 'resume_started',
}),
record: async (input) => { records.push(input); return { turnId: input.turnId }; },
});
await submitAndRecordWorkHubSurfaceInput({
controller,
request: { requestId: 'resume-1', text: 'Resume Payments' },
recordedUserText: 'Resume Payments',
summary: () => 'Resume requested. See the target Session for current progress.',
onSummaryError: () => assert.fail('conversation write must succeed'),
});
assert.deepEqual(records, [{
turnId: 'resume-1', userText: 'Resume Payments',
assistantText: 'Resume requested. See the target Session for current progress.', disposition: 'summary',
}]);
});

test('lease retires only after an acknowledged submission', async () => {
const { storage } = memoryStorage();
let sends = 0;
Expand Down
Loading