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
255 changes: 255 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19567,6 +19567,261 @@ describe('Session', () => {
expect(publishedActivities).toEqual(['idle', 'running']);
});

it('stamps a Goal turn’s tool results with the permit, and its own reads as bookkeeping', async () => {
// The evidence catalog derives provenance from this stamp: an
// unstamped tool result yields no catalog entry at all, so an ACP
// Goal could never cite an external_fact and could never prove a
// completion with anything but the model's own words.
const permit: core.GoalTurnPermit = {
goalId: 'goal-1',
revision: 1,
turnId: 'turn-stamp',
};
mockGoalRuntime.getSnapshot.mockReturnValue({
v: 2,
activity: 'running',
goal: {
goalId: 'goal-1',
revision: 1,
objective: 'check weather',
status: 'active',
evidenceCursor: { recordId: 'cursor-1' },
turnCount: 0,
activeTimeMs: 0,
tokensUsed: 0,
createdAt: 1234,
updatedAt: 1234,
},
});
mockGoalRuntime.permitForTurn.mockImplementation((turnKey: string) =>
turnKey === 'goal-runtime:turn-stamp' ? permit : undefined,
);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockToolRegistry.getTool.mockImplementation((name: string) => ({
name,
displayName: name,
kind: core.Kind.Read,
schema: { name, description: name, parameters: {} },
validateToolParams: vi.fn().mockReturnValue(null),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue(name),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi.fn().mockResolvedValue(
name === 'run_shell_command'
? {
llmContent: 'command failed',
returnDisplay: 'command failed',
error: {
message: 'command failed',
type: core.ToolErrorType.EXECUTION_FAILED,
},
}
: { llmContent: 'ok', returnDisplay: 'ok' },
),
}));
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{ id: 'call-read', name: 'read_file', args: {} },
{ id: 'call-goal', name: 'get_goal', args: {} },
{ id: 'call-failed', name: 'run_shell_command', args: {} },
],
},
},
]),
)
.mockResolvedValue(createEmptyStream());

expect(boundGoalHost).toBeDefined();
await boundGoalHost!.startGoalTurn({
permit,
continuationContext: 'check weather',
});

await vi.waitFor(() => {
expect(mockGoalRuntime.finishTurn).toHaveBeenCalledWith(permit);
});

const optionsFor = (callId: string) =>
mockChatRecordingService.recordToolResult.mock.calls.find(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId === callId,
)?.[2];
expect(optionsFor('call-read')).toEqual({ goalContext: permit });
expect(optionsFor('call-goal')).toEqual({
goalContext: permit,
provenance: 'goal_runtime',
});
// A failed command is evidence too -- it is exactly what an
// `infeasible` completion cites -- so the stamp must not depend on
// the tool succeeding.
expect(
mockChatRecordingService.recordToolResult.mock.calls.find(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId ===
'call-failed',
)?.[1],
).toMatchObject({ status: 'error' });
expect(optionsFor('call-failed')).toEqual({ goalContext: permit });
});

it('does not stamp a background-notification turn with a permit inherited by lineage', async () => {
// A notification fires from async resources created inside the turn
// that spawned the task, so the Goal store is inherited into it long
// after that turn ended. The notification turn is not a Goal turn:
// its tool results must record unstamped, or they would become
// external_fact evidence for a turn that never made those calls.
const permit: core.GoalTurnPermit = {
goalId: 'goal-1',
revision: 1,
turnId: 'turn-stale',
};
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockToolRegistry.getTool.mockImplementation((name: string) => ({
name,
displayName: name,
kind: core.Kind.Read,
schema: { name, description: name, parameters: {} },
validateToolParams: vi.fn().mockReturnValue(null),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue(name),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi
.fn()
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
}));
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{ id: 'call-notified', name: 'read_file', args: {} },
],
},
},
]),
)
.mockResolvedValue(createEmptyStream());

// Enqueue from inside a Goal turn's store, as a task completion
// handler created during that turn would.
const notification = core.goalTurnContext.run(permit, () =>
session.enqueueBackgroundNotification({
displayText: 'Background agent completed.',
modelText: '<task-notification />',
taskId: 'agent-stale',
status: 'completed',
kind: 'agent',
}),
);
await expect(notification).resolves.toEqual({ accepted: true });
await vi.waitFor(() => {
expect(
mockChatRecordingService.recordToolResult.mock.calls.some(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId ===
'call-notified',
),
).toBe(true);
});

const call = mockChatRecordingService.recordToolResult.mock.calls.find(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId ===
'call-notified',
)!;
expect(call[2]).toBeUndefined();
});

it('does not stamp a cron turn with a permit inherited by lineage', async () => {
// `#drainCronQueue()` is fired from inside Goal-turn code paths (the
// turn settle's `finally`, among others), so a cron item can drain
// while a Goal permit is live in the store. A cron turn is never a
// Goal turn: its tool results must record unstamped, exactly like
// the notification turn above.
const permit: core.GoalTurnPermit = {
goalId: 'goal-1',
revision: 1,
turnId: 'turn-stale-cron',
};
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockToolRegistry.getTool.mockImplementation((name: string) => ({
name,
displayName: name,
kind: core.Kind.Read,
schema: { name, description: name, parameters: {} },
validateToolParams: vi.fn().mockReturnValue(null),
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue(name),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi
.fn()
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
}));
let fireCron!: (job: { prompt: string; cronExpr: string }) => void;
const scheduler = {
hasPendingWork: true,
enableDurable: vi.fn().mockResolvedValue(undefined),
start: vi.fn(
(callback: (job: { prompt: string; cronExpr: string }) => void) => {
fireCron = callback;
},
),
stop: vi.fn(),
list: vi.fn().mockReturnValue([]),
getExitSummary: vi.fn().mockReturnValue(undefined),
};
mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler);
session.startCronScheduler();
await vi.waitFor(() => expect(scheduler.start).toHaveBeenCalled());
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{ id: 'call-cron', name: 'read_file', args: {} },
],
},
},
]),
)
.mockResolvedValue(createEmptyStream());

// Fire from inside a Goal turn's store, as a drain triggered from a
// Goal-turn code path would be.
core.goalTurnContext.run(permit, () =>
fireCron({ prompt: 'cron work', cronExpr: '* * * * *' }),
);
await vi.waitFor(() => {
expect(
mockChatRecordingService.recordToolResult.mock.calls.some(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId ===
'call-cron',
),
).toBe(true);
});

const call = mockChatRecordingService.recordToolResult.mock.calls.find(
(call: unknown[]) =>
(call[1] as { callId?: string } | undefined)?.callId ===
'call-cron',
)!;
expect(call[2]).toBeUndefined();
});

it('runs a host-scheduled Goal turn with the canonical permit', async () => {
const permit: core.GoalTurnPermit = {
goalId: 'goal-1',
Expand Down
41 changes: 30 additions & 11 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import {
refreshMemoryAfterManagedWrite,
refreshMemoryInstruction,
GoalPersistenceUnavailableError,
ambientGoalToolResultProvenance,
goalTurnContext,
sessionIdContext,
promptIdContext,
Expand Down Expand Up @@ -8092,10 +8093,14 @@ export class Session implements SessionContext {
* `_meta.source='cron'`, streams the model response, and handles tool calls.
*/
async #executeCronPrompt(item: CronQueueItem): Promise<void> {
// Same session-ID binding rationale as #executePrompt.
return runWithInvocationContext(undefined, () =>
sessionIdContext.run(this.config.getSessionId(), () =>
this.#executeCronPromptInner(item),
// Same session-ID binding rationale as #executePrompt, and the same
// reason to leave the Goal store as the notification drain: a cron turn
// is never a Goal turn, whatever lineage it was scheduled from.
return goalTurnContext.exit(() =>
runWithInvocationContext(undefined, () =>
sessionIdContext.run(this.config.getSessionId(), () =>
this.#executeCronPromptInner(item),
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
),
),
);
}
Expand Down Expand Up @@ -8872,9 +8877,17 @@ export class Session implements SessionContext {
this.currentShellNotificationActive = item.kind === 'shell';
this.#activeWorkChanged();
try {
await runWithInvocationContext(undefined, () =>
sessionIdContext.run(this.config.getSessionId(), () =>
this.#executeBackgroundNotificationPromptInner(item),
// A notification fires from async resources created inside the
// turn that spawned the task, so a Goal permit can reach here by
// lineage after that turn is long over. This is not a Goal turn:
// leave the store, as #executePrompt does for every non-Goal turn,
// or the notification's tool results would be stamped as evidence
// for a turn that never made those calls.
await goalTurnContext.exit(() =>
runWithInvocationContext(undefined, () =>
sessionIdContext.run(this.config.getSessionId(), () =>
this.#executeBackgroundNotificationPromptInner(item),
),
),
);
} finally {
Expand Down Expand Up @@ -9731,13 +9744,19 @@ export class Session implements SessionContext {
) {
return;
}
this.config
.getChatRecordingService()
?.recordToolResult(finalized[index].responseParts, {
const goalProvenance = ambientGoalToolResultProvenance(record.toolName);
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
this.config.getChatRecordingService()?.recordToolResult(
finalized[index].responseParts,
{
...record.metadata,
persistedOutputFiles: finalized[index].persistedOutputFiles,
artifacts: finalized[index].artifacts,
});
},
// Passed only inside a Goal turn: outside one this call keeps its
// former two-argument shape, so nothing about ordinary recording
// changes.
...(goalProvenance ? ([goalProvenance] as const) : ([] as const)),
);
});
return {
...result,
Expand Down
Loading
Loading