Skip to content

Commit c21ae68

Browse files
authored
feat(cloud-agent-next): add exact message result polling (#3667)
1 parent b408314 commit c21ae68

11 files changed

Lines changed: 1384 additions & 59 deletions

File tree

services/cloud-agent-next/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ The recommended V2 flow is:
5656
- `prepareSession` - Create a fully prepared session with workspace, git clone, and configuration
5757
- `updateSession` - Update a prepared (not yet initiated) session
5858
- `getSession` - Query session metadata (no secrets)
59+
- `getMessageResult` - Poll lifecycle state and terminal assistant text for one submitted turn
5960
- `initiateFromKilocodeSessionV2` - Start execution on a prepared session
6061
- `sendMessageV2` - Send follow-up messages (output via `/stream` WebSocket)
6162
- `deleteSession` - Delete a session and clean up resources
@@ -67,6 +68,28 @@ The recommended V2 flow is:
6768

6869
All endpoints require a kilocode api token except `/stream` which uses short lived ws tickets.
6970

71+
### Message Result Retrieval
72+
73+
Use the bearer-protected `GET /trpc/getMessageResult` query to poll one durably submitted Cloud Agent turn. Supply `cloudAgentSessionId` and the submitted `messageId`.
74+
75+
```text
76+
GET /trpc/getMessageResult?input={"cloudAgentSessionId":"agent_<uuid>","messageId":"msg_<id>"}
77+
Authorization: Bearer <kilocode-api-token>
78+
```
79+
80+
The response includes only safe fields: `cloudAgentSessionId`, `messageId`, lifecycle status and timestamps, optional structured `completionSource`, `failure`, `gateResult`, and assistant text correlated to the selected submitted turn. It never returns prompts, tokens, callback details, or raw diagnostics.
81+
82+
| Stored lifecycle state | Public status |
83+
|---|---|
84+
| `queued` | `queued` |
85+
| `accepted` | `running` |
86+
| `completed` | `completed` |
87+
| `failed` | `failed` |
88+
| `interrupted` | `interrupted` |
89+
| Pending-only compatibility row | `queued` |
90+
91+
The query returns `NOT_FOUND` for missing sessions, cross-user session lookups, and unknown message IDs.
92+
7093
## Usage Examples
7194

7295
### TypeScript Client (Recommended)

services/cloud-agent-next/src/persistence/CloudAgentSession.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ import {
108108
createMessageSettlementOutbox,
109109
type MessageSettlementOutbox,
110110
} from '../session/message-settlement-outbox.js';
111+
import {
112+
resolveSessionMessageResult,
113+
type MessageResultRPCResponse,
114+
} from '../session/message-result.js';
111115
import {
112116
createAgentRuntime,
113117
type AgentRuntime,
@@ -979,6 +983,41 @@ export class CloudAgentSession extends DurableObject<WorkerEnv> {
979983
return this.eventQueries.getLatestAssistantMessage(sessionId, metadata.auth.kiloSessionId);
980984
}
981985

986+
async getMessageResult(messageId: string): Promise<MessageResultRPCResponse> {
987+
const metadata = await this.getMetadata();
988+
if (!metadata) return { type: 'session-not-found' };
989+
990+
const resolved = await resolveSessionMessageResult(this.ctx.storage, messageId);
991+
if (!resolved) return { type: 'message-not-found' };
992+
if (resolved.type === 'state-invalid') return resolved;
993+
994+
const sessionId = await this.requireSessionId();
995+
const assistantMessage =
996+
metadata.auth.kiloSessionId && resolved.assistantLookup
997+
? this.eventQueries.getAssistantMessageById(
998+
sessionId,
999+
metadata.auth.kiloSessionId,
1000+
resolved.assistantLookup.messageId,
1001+
resolved.assistantLookup.parentMessageId
1002+
)
1003+
: null;
1004+
const assistant = assistantMessage
1005+
? {
1006+
messageId: assistantMessage.info.id,
1007+
text: extractAssistantTextFromParts(assistantMessage.parts) || undefined,
1008+
}
1009+
: undefined;
1010+
1011+
return {
1012+
type: 'found',
1013+
result: {
1014+
cloudAgentSessionId: sessionId,
1015+
...resolved.result,
1016+
...(assistant ? { assistant } : {}),
1017+
},
1018+
};
1019+
}
1020+
9821021
private async getLatestAssistantMessageText(): Promise<string | undefined> {
9831022
try {
9841023
const message = await this.getLatestAssistantMessage();

services/cloud-agent-next/src/router.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ type MockSessionStub = {
9191
getActiveExecutionId?: ReturnType<typeof vi.fn>;
9292
getExecution?: ReturnType<typeof vi.fn>;
9393
getLatestAssistantMessage?: ReturnType<typeof vi.fn>;
94+
getMessageResult?: ReturnType<typeof vi.fn>;
9495
createTerminal?: ReturnType<typeof vi.fn>;
9596
resizeTerminal?: ReturnType<typeof vi.fn>;
9697
closeTerminal?: ReturnType<typeof vi.fn>;
@@ -1505,6 +1506,111 @@ describe('router sessionId validation', () => {
15051506
});
15061507
});
15071508

1509+
describe('getMessageResult procedure', () => {
1510+
const sessionId: SessionId = 'agent_12345678-1234-1234-1234-123456789abc';
1511+
const messageId = 'msg_018f1e2d3c4bAbCdEfGhIjKlMn';
1512+
let mockContext: TRPCContext;
1513+
let caller: ReturnType<typeof appRouter.createCaller>;
1514+
let cloudAgentSession: MockCAS;
1515+
let mockGetMessageResult: ReturnType<typeof vi.fn>;
1516+
1517+
beforeEach(() => {
1518+
vi.clearAllMocks();
1519+
mockGetMessageResult = vi.fn().mockResolvedValue({
1520+
type: 'found',
1521+
result: {
1522+
cloudAgentSessionId: sessionId,
1523+
messageId,
1524+
status: 'completed',
1525+
createdAt: 1,
1526+
terminalAt: 2,
1527+
assistant: { messageId: 'assistant_done', text: 'done' },
1528+
},
1529+
});
1530+
mockContext = {
1531+
userId: 'test-user-123',
1532+
authToken: 'test-token',
1533+
botId: undefined,
1534+
request: {} as Request,
1535+
env: {
1536+
CLOUD_AGENT_SESSION: {
1537+
idFromName: vi.fn((id: string) => ({ id })),
1538+
get: vi.fn(() => ({ getMessageResult: mockGetMessageResult })),
1539+
} as unknown as TRPCContext['env']['CLOUD_AGENT_SESSION'],
1540+
} as unknown as TRPCContext['env'],
1541+
};
1542+
cloudAgentSession = mockContext.env.CLOUD_AGENT_SESSION as unknown as MockCAS;
1543+
caller = appRouter.createCaller(mockContext);
1544+
});
1545+
1546+
it('returns an ownership-isolated safe exact message result with one Durable Object RPC', async () => {
1547+
await expect(
1548+
caller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1549+
).resolves.toEqual({
1550+
cloudAgentSessionId: sessionId,
1551+
messageId,
1552+
status: 'completed',
1553+
createdAt: 1,
1554+
terminalAt: 2,
1555+
assistant: { messageId: 'assistant_done', text: 'done' },
1556+
});
1557+
expect(cloudAgentSession.idFromName).toHaveBeenCalledWith(`test-user-123:${sessionId}`);
1558+
expect(mockGetMessageResult).toHaveBeenCalledOnce();
1559+
expect(mockGetMessageResult).toHaveBeenCalledWith(messageId);
1560+
});
1561+
1562+
it('returns Session not found when the Durable Object has no metadata', async () => {
1563+
mockGetMessageResult.mockResolvedValue({ type: 'session-not-found' });
1564+
await expect(
1565+
caller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1566+
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Session not found' });
1567+
});
1568+
1569+
it('returns Message not found for an unknown message ID', async () => {
1570+
mockGetMessageResult.mockResolvedValue({ type: 'message-not-found' });
1571+
await expect(
1572+
caller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1573+
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Message not found' });
1574+
});
1575+
1576+
it('fails closed when persisted message state is invalid', async () => {
1577+
mockGetMessageResult.mockResolvedValue({ type: 'state-invalid' });
1578+
await expect(
1579+
caller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1580+
).rejects.toMatchObject({
1581+
code: 'INTERNAL_SERVER_ERROR',
1582+
message: 'Message result unavailable',
1583+
});
1584+
});
1585+
1586+
it('requires authentication', async () => {
1587+
const unauthenticatedCaller = appRouter.createCaller({
1588+
...mockContext,
1589+
userId: undefined,
1590+
authToken: undefined,
1591+
} as unknown as TRPCContext);
1592+
await expect(
1593+
unauthenticatedCaller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1594+
).rejects.toThrow('Authentication required');
1595+
});
1596+
1597+
it('rejects extra sensitive RPC response fields at the output boundary', async () => {
1598+
mockGetMessageResult.mockResolvedValue({
1599+
type: 'found',
1600+
result: {
1601+
cloudAgentSessionId: sessionId,
1602+
messageId,
1603+
status: 'failed',
1604+
createdAt: 1,
1605+
error: 'private raw error',
1606+
},
1607+
});
1608+
await expect(
1609+
caller.getMessageResult({ cloudAgentSessionId: sessionId, messageId })
1610+
).rejects.toThrow();
1611+
});
1612+
});
1613+
15081614
describe('router terminal procedures', () => {
15091615
it('creates a terminal through the session Durable Object', async () => {
15101616
const createTerminal = vi.fn().mockResolvedValue({

services/cloud-agent-next/src/router/handlers/session-management.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import {
1818
GetSessionOutput,
1919
GetSessionHealthInput,
2020
GetSessionHealthOutput,
21+
GetMessageResultInput,
22+
GetMessageResultOutput,
2123
GetLatestAssistantMessageInput,
2224
GetLatestAssistantMessageOutput,
2325
} from '../schemas.js';
2426
import { readProfileBundle } from '../../session-profile.js';
2527
import type { CloudAgentSession } from '../../persistence/CloudAgentSession.js';
2628
import type { CloudAgentSessionState } from '../../persistence/types.js';
29+
import type { MessageResultRPCResponse } from '../../session/message-result.js';
2730

2831
function publicRepositoryFields(metadata: CloudAgentSessionState): {
2932
githubRepo?: string;
@@ -402,6 +405,42 @@ export function createSessionManagementHandlers() {
402405
});
403406
}),
404407

408+
getMessageResult: protectedProcedure
409+
.input(GetMessageResultInput)
410+
.output(GetMessageResultOutput)
411+
.query(async ({ input, ctx }) => {
412+
return withLogTags({ source: 'getMessageResult' }, async () => {
413+
const sessionId = input.cloudAgentSessionId as SessionId;
414+
const { userId, env } = ctx;
415+
const doKey = `${userId}:${sessionId}`;
416+
const getStub = () =>
417+
env.CLOUD_AGENT_SESSION.get(env.CLOUD_AGENT_SESSION.idFromName(doKey));
418+
419+
const response = await withDORetry<
420+
DurableObjectStub<CloudAgentSession>,
421+
MessageResultRPCResponse
422+
>(
423+
getStub,
424+
async stub => await stub.getMessageResult(input.messageId),
425+
'getMessageResult'
426+
);
427+
if (response.type === 'session-not-found') {
428+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' });
429+
}
430+
if (response.type === 'message-not-found') {
431+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Message not found' });
432+
}
433+
if (response.type === 'state-invalid') {
434+
throw new TRPCError({
435+
code: 'INTERNAL_SERVER_ERROR',
436+
message: 'Message result unavailable',
437+
});
438+
}
439+
440+
return response.result;
441+
});
442+
}),
443+
405444
getLatestAssistantMessage: protectedProcedure
406445
.input(GetLatestAssistantMessageInput)
407446
.output(GetLatestAssistantMessageOutput)

services/cloud-agent-next/src/router/schemas.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
ExecutionResponse,
4+
GetMessageResultInput,
5+
GetMessageResultOutput,
46
GetSessionOutput,
57
InitiateFromPreparedSessionInput,
68
LegacyExecutionResponse,
@@ -331,6 +333,111 @@ describe('message ID schema validation', () => {
331333
});
332334
});
333335

336+
describe('getMessageResult contract', () => {
337+
const baseOutput = {
338+
cloudAgentSessionId: validSessionId,
339+
messageId: validMessageId,
340+
status: 'completed' as const,
341+
createdAt: 1,
342+
};
343+
344+
it('requires an exact lookup input while rejecting invalid or unknown fields', () => {
345+
expect(GetMessageResultInput.safeParse({ cloudAgentSessionId: validSessionId }).success).toBe(
346+
false
347+
);
348+
expect(
349+
GetMessageResultInput.safeParse({
350+
cloudAgentSessionId: validSessionId,
351+
messageId: validMessageId,
352+
}).success
353+
).toBe(true);
354+
expect(GetMessageResultInput.safeParse({ cloudAgentSessionId: 'agent_invalid' }).success).toBe(
355+
false
356+
);
357+
expect(
358+
GetMessageResultInput.safeParse({ cloudAgentSessionId: validSessionId, messageId: 'msg_bad' })
359+
.success
360+
).toBe(false);
361+
expect(
362+
GetMessageResultInput.safeParse({ cloudAgentSessionId: validSessionId, unknown: true })
363+
.success
364+
).toBe(false);
365+
});
366+
367+
it('accepts public statuses and allowlisted structured result fields', () => {
368+
for (const status of ['queued', 'running', 'completed', 'failed', 'interrupted']) {
369+
expect(GetMessageResultOutput.safeParse({ ...baseOutput, status }).success).toBe(true);
370+
}
371+
expect(
372+
GetMessageResultOutput.safeParse({
373+
...baseOutput,
374+
queuedAt: 2,
375+
acceptedAt: 3,
376+
terminalAt: 4,
377+
completionSource: 'assistant_message_event',
378+
gateResult: 'fail',
379+
assistant: { messageId: 'assistant_1', text: 'safe answer' },
380+
}).success
381+
).toBe(true);
382+
expect(
383+
GetMessageResultOutput.safeParse({
384+
...baseOutput,
385+
status: 'failed',
386+
queuedAt: 2,
387+
acceptedAt: 3,
388+
terminalAt: 4,
389+
completionSource: 'wrapper_failure',
390+
failure: { stage: 'agent_activity', code: 'assistant_error', attempts: 2 },
391+
}).success
392+
).toBe(true);
393+
});
394+
395+
it('fails closed on contradictory lifecycle result fields', () => {
396+
for (const output of [
397+
{ ...baseOutput, status: 'queued', acceptedAt: 2 },
398+
{ ...baseOutput, status: 'queued', terminalAt: 2 },
399+
{ ...baseOutput, status: 'running', completionSource: 'assistant_message_event' },
400+
{ ...baseOutput, status: 'queued', failure: { attempts: 1 } },
401+
{ ...baseOutput, status: 'failed', assistant: { messageId: 'assistant_1', text: 'wrong' } },
402+
{ ...baseOutput, status: 'interrupted', gateResult: 'fail' },
403+
]) {
404+
expect(GetMessageResultOutput.safeParse(output).success).toBe(false);
405+
}
406+
});
407+
408+
it('fails closed on extra top-level and nested fields', () => {
409+
for (const extra of [
410+
{ error: 'token' },
411+
{ failureReason: 'token' },
412+
{ callbackTarget: { url: 'https://example.com', headers: { Authorization: 'token' } } },
413+
]) {
414+
expect(GetMessageResultOutput.safeParse({ ...baseOutput, ...extra }).success).toBe(false);
415+
}
416+
expect(
417+
GetMessageResultOutput.safeParse({
418+
...baseOutput,
419+
status: 'failed',
420+
failure: { attempts: -1 },
421+
}).success
422+
).toBe(false);
423+
expect(
424+
GetMessageResultOutput.safeParse({
425+
...baseOutput,
426+
status: 'failed',
427+
failure: { error: 'token' },
428+
}).success
429+
).toBe(false);
430+
expect(
431+
GetMessageResultOutput.safeParse({ ...baseOutput, assistant: { text: 'missing identity' } })
432+
.success
433+
).toBe(false);
434+
expect(
435+
GetMessageResultOutput.safeParse({ ...baseOutput, assistant: { parts: [], info: {} } })
436+
.success
437+
).toBe(false);
438+
});
439+
});
440+
334441
describe('API output schemas omit executionId', () => {
335442
it('StartSessionOutput rejects executionId', () => {
336443
const result = StartSessionOutput.strict().safeParse({

0 commit comments

Comments
 (0)