Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
17 changes: 13 additions & 4 deletions apps/server/src/modules/agent/acp/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('../../workspace/paths.js', () => ({
}));

import { runAcpAgent } from './service.js';
import { inferredIntentFromFoldedToolCall } from '../conversation/transcript/history.js';
import { inkInterpretationFromFoldedToolCall } from '../conversation/transcript/history.js';
import {
activeInkIntentOwnerNodeId,
isActiveInkIntentTurn,
Expand Down Expand Up @@ -142,7 +142,10 @@ describe('runAcpAgent durable acceptance', () => {
'question-1',
);
publishExternalInkReport('canvas-1', 'thread-1', token, {
report: { status: 'inferred', text: 'Explain the drawing' },
report: {
text: 'Explain the drawing',
explanation: 'The arrow refers to the second shape.',
},
renamed: true,
});
const events = ctx.drainHostEvents?.() ?? [];
Expand All @@ -151,8 +154,14 @@ describe('runAcpAgent durable acceptance', () => {
expect(
folder
.result()
.map((message) => inferredIntentFromFoldedToolCall(message, false)),
).toContain('Explain the drawing');
.map((message) =>
inkInterpretationFromFoldedToolCall(message, false),
),
).toContainEqual({
state: 'reported',
text: 'Explain the drawing',
explanation: 'The arrow refers to the second shape.',
});
expect(ctx.drainHostEvents?.()).toEqual([]);
if (outcome === 'error') throw new Error('Agent failed');
if (outcome === 'cancel') {
Expand Down
125 changes: 125 additions & 0 deletions apps/server/src/modules/agent/agent-thread.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,131 @@ function invocationOptions() {
}

describe('AgentThreadService', () => {
it.each([
{ mode: 'ask', invocationToken: undefined, expectedMode: 'ask' },
{ mode: 'operate', invocationToken: undefined, expectedMode: 'ask' },
{ mode: 'ask', invocationToken: 'previous-turn', expectedMode: 'ask' },
{
mode: 'operate',
invocationToken: 'previous-turn',
expectedMode: 'operate',
},
] as const)(
'admits Ink with normal mode resolution for an internal Ask target: %j',
async ({ mode, invocationToken, expectedMode }) => {
const target: FixedAgentNodeTarget = {
...TARGET,
agentBinding: { kind: 'internal' },
agentMode: 'ask',
invocationToken,
};
const h = createHarness({ target });
const envelope: ChatEnvelope = {
...ENVELOPE,
user: { ...ENVELOPE.user, inputKind: 'ink-intent' },
};
const invocation = await h.service.invoke({
...invocationOptions(),
mode,
requestBinding: { kind: 'internal' },
envelope,
submission: createChatSubmission(envelope, [
{ type: 'text', text: 'Prepared Ink request' },
]),
});
for await (const _event of invocation.events) {
// Drain the canonical invocation stream.
}
expect(h.runInternal).toHaveBeenCalledWith(
expect.objectContaining({ scope: expectedMode, envelope }),
);
expect(invocation.binding).toEqual({ kind: 'internal' });
expect(h.realizeExternal).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
expect(h.service.isActive('thread-a', 'canvas-a')).toBe(false);
expect(h.failLifecycle).not.toHaveBeenCalled();
expect(target.agentMode).toBe('ask');
},
);

it('admits Ink to an unanchored Ask conversation without upgrading its mode', async () => {
const h = createHarness({ target: null });
const envelope: ChatEnvelope = {
...ENVELOPE,
user: { ...ENVELOPE.user, inputKind: 'ink-intent' },
};
const invocation = await h.service.invoke({
...invocationOptions(),
fixedTarget: null,
requestBinding: { kind: 'internal' },
envelope,
submission: createChatSubmission(envelope, [
{ type: 'text', text: 'Prepared Ink request' },
]),
});
for await (const _event of invocation.events) {
// Drain the canonical invocation stream.
}
expect(h.runInternal).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'ask', envelope }),
);
expect(h.realizeExternal).not.toHaveBeenCalled();
});

it.each(['ask', 'operate'] as const)(
'admits external Ink without changing its binding under the %s UI mode',
async (mode) => {
const h = createHarness();
const envelope: ChatEnvelope = {
...ENVELOPE,
user: { ...ENVELOPE.user, inputKind: 'ink-intent' },
};
const invocation = await h.service.invoke({
...invocationOptions(),
mode,
envelope,
submission: createChatSubmission(envelope, [
{ type: 'text', text: 'Prepared external Ink request' },
]),
});
for await (const _event of invocation.events) {
// Drain the canonical invocation stream.
}
expect(invocation.binding).toEqual(TARGET.agentBinding);
expect(h.runExternal).toHaveBeenCalledWith(
expect.objectContaining({ binding: TARGET.agentBinding, envelope }),
);
expect(h.runInternal).not.toHaveBeenCalled();
},
);

it('accepts Ink for a persisted Operate target without changing its mode', async () => {
const target: FixedAgentNodeTarget = {
...TARGET,
agentBinding: { kind: 'internal' },
agentMode: 'operate',
};
const h = createHarness({ target });
const envelope: ChatEnvelope = {
...ENVELOPE,
user: { ...ENVELOPE.user, inputKind: 'ink-intent' },
};
const invocation = await h.service.invoke({
...invocationOptions(),
requestBinding: { kind: 'internal' },
envelope,
submission: createChatSubmission(envelope, [
{ type: 'text', text: 'Prepared Ink request' },
]),
});
for await (const _event of invocation.events) {
// Drain the canonical invocation stream.
}
expect(h.runInternal).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'operate' }),
);
});

it('keeps slow input preparation inside cancellable admission and never dispatches after stop', async () => {
const h = createHarness();
let finishPreparation!: () => void;
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/modules/agent/agent-thread.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ export class AgentThreadService {
let agentTarget: AgentNodeTarget | null = null;
let fixedTarget: FixedAgentNodeTarget | null = null;
let binding: AgentBinding = options.requestBinding ?? { kind: 'internal' };
let effectiveMode = options.mode;
let externalRealization: RealizedExternalAgentThread | undefined;
let envelope =
typeof options.envelope === 'function' ? undefined : options.envelope;
Expand Down Expand Up @@ -480,6 +481,9 @@ export class AgentThreadService {
options.canvasId,
options.threadId,
);
effectiveMode = !agentTarget?.invocationToken
? (agentTarget?.agentMode ?? options.mode)
: options.mode;
if (agentTarget) {
binding =
agentTarget.agentBinding ?? fixedTarget?.agentBinding ?? binding;
Expand Down Expand Up @@ -610,9 +614,7 @@ export class AgentThreadService {
signal,
spacePrompt,
externalRealization,
mode: !agentTarget?.invocationToken
? (agentTarget?.agentMode ?? options.mode)
: options.mode,
mode: effectiveMode,
onExecutionCreated: agentTarget
? async () => {
await this.dependencies.confirmBinding?.(agentTarget, {
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/modules/agent/agent.history-page.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Fastify from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
history: vi.fn(),
historyPage: vi.fn(),
record: vi.fn(() => undefined),
isActive: vi.fn(() => false),
Expand All @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('../agent/agenetes/drivers.js', () => ({
INTERNAL_DRIVER_KIND: 'internal',
agenetes: {
history: mocks.history,
historyPage: mocks.historyPage,
record: mocks.record,
},
Expand All @@ -35,6 +37,7 @@ vi.mock('../workspace/paths.js', () => ({
canvasAcpNamespace: (canvasId: string) => ({ name: canvasId }),
}));

import { createChatSubmission } from './agenetes/handle.js';
import agentRoutes from './agent.route.js';

async function request(url: string) {
Expand All @@ -47,12 +50,53 @@ async function request(url: string) {

describe('GET /agent/history/:threadId/page', () => {
beforeEach(() => {
mocks.history.mockReset();
mocks.historyPage.mockReset();
mocks.record.mockReset();
mocks.record.mockReturnValue(undefined);
mocks.isActive.mockReturnValue(false);
});

it.each([true, false])(
'projects pending only for a currently running incomplete Ink turn (running=%s)',
async (running) => {
const turn = {
request: createChatSubmission({
user: { text: '', inputKind: 'ink-intent', attachments: [] },
skills: { invokedIds: [], resolved: [] },
focus: {
selection: {
refs: [],
selectedIds: [],
imageAttachments: [],
snapshotAttachments: [],
},
},
}),
transcript: [],
isIncomplete: true,
};
mocks.isActive.mockReturnValue(running);
mocks.history.mockResolvedValue({ turns: [turn] });
mocks.historyPage.mockResolvedValue({
groups: [
{ id: 'turn-1', turns: [turn], isActive: true, activeTurnIndex: 0 },
],
hasMore: false,
});
const full = await request('/agent/history/thread-1?canvasId=canvas-1');
const paged = await request(
'/agent/history/thread-1/page?canvasId=canvas-1&limit=3',
);
expect(full.statusCode).toBe(200);
expect(paged.statusCode).toBe(200);
expect(full.json().messages[0].inkInterpretation).toEqual({
state: running ? 'pending' : 'missing',
});
expect(paged.json().turns[0].messages).toEqual(full.json().messages);
},
);

it.each([
'/agent/history/thread-1/page?limit=3',
'/agent/history/thread-1/page?canvasId=canvas-1&limit=0',
Expand Down
60 changes: 60 additions & 0 deletions apps/server/src/modules/agent/agent.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,66 @@ afterEach(() => {
});

describe('POST /agent', () => {
it('admits Ink in Ask mode and streams acceptance without upgrading the request', async () => {
mocks.beginPreparation.mockReturnValue({
signal: new AbortController().signal,
finish: vi.fn(),
});
mocks.resolveFixedTarget.mockResolvedValue(null);
mocks.resolveTarget.mockResolvedValue(null);
mocks.buildChatEnvelope.mockResolvedValue(ENVELOPE);
mocks.invoke.mockImplementationOnce(async (options) => {
await options.envelope();
return {
binding: { kind: 'internal' },
fixedTarget: null,
signal: new AbortController().signal,
acceptance: Promise.resolve({
threadId: 'thread-a',
turnStartSeq: 1,
}),
events: events(),
dispose: vi.fn().mockResolvedValue(undefined),
};
});
const app = Fastify();
await app.register(agentRoutes, { prefix: '/agent' });
try {
const response = await app.inject({
method: 'POST',
url: '/agent',
payload: {
inputKind: 'ink-intent',
content: '',
threadId: 'thread-a',
canvasId: 'canvas-a',
mode: 'ask',
canvasContext: {
selectedNodes: [
{
id: 'sketch-a',
type: 'sketch',
strokeIds: ['stroke-a'],
},
],
},
},
});
expect(response.statusCode, response.body).toBe(200);
expect(mocks.invoke).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'ask' }),
);
expect(mocks.buildChatEnvelope).toHaveBeenCalledWith(
expect.objectContaining({ inputKind: 'ink-intent', content: '' }),
);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('event: accepted');
expect(response.body).toContain('event: done');
} finally {
await app.close();
}
});

it('returns durable acceptance from the shared stop path', async () => {
mocks.stopAndWait.mockResolvedValue({
stopped: true,
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/modules/agent/agent.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ const agentRoutes: FastifyPluginAsync = async (
}
buildHistoryFromTurns([turn], messages, {
recoverInternalToolNames,
...(index === group.activeTurnIndex &&
agentThreadService.isActive(threadId, canvasId)
? { activeTurnIndex: 0 }
: {}),
});
});
return {
Expand Down Expand Up @@ -299,6 +303,10 @@ const agentRoutes: FastifyPluginAsync = async (
(record?.spec as { kind?: unknown } | undefined)?.kind === 'internal';
buildHistoryFromTurns(turns, messages, {
recoverInternalToolNames: isInternalThread,
...(turns.at(-1)?.isIncomplete &&
agentThreadService.isActive(threadId, canvasId)
? { activeTurnIndex: turns.length - 1 }
: {}),
});

return reply.send({ threadId, messages });
Expand Down
Loading
Loading