Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/server/request_handler/default_request_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ export class DefaultRequestHandler implements A2ARequestHandler {
if (event.kind === 'message') {
firstResult = event;
} else {
firstResult = resultManager.getCurrentTask();
// Defense-in-depth: hand the caller a snapshot, not a live reference
// to ResultManager's internal state.
const current = resultManager.getCurrentTask();
firstResult = current ? structuredClone(current) : undefined;
}
if (firstResult) {
options.firstResultResolver(firstResult);
Expand Down
134 changes: 52 additions & 82 deletions src/server/result_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export class ResultManager {
// The ExecutionEventQueue will stop after a message event.
} else if (event.kind === 'task') {
const taskEvent = event as Task;
this.currentTask = { ...taskEvent }; // Make a copy
// Deep clone so that subsequent copy-on-write updates never alias the
// caller's event object (including its history/artifacts arrays).
this.currentTask = structuredClone(taskEvent);

// Ensure the latest user message is in history if not already present
if (this.latestUserMessage) {
Expand All @@ -47,107 +49,75 @@ export class ResultManager {
await this.saveCurrentTask();
} else if (event.kind === 'status-update') {
const updateEvent = event as TaskStatusUpdateEvent;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To fully isolate the ResultManager state from subsequent mutations by the executor and to avoid a potential race condition during the await at line 58, consider deep-cloning the updateEvent. Currently, next.status (line 68) aliases the status object from the event, meaning any later mutation of that object by the executor would be reflected in ResultManager's internal state and subsequent snapshots.

Suggested change
const updateEvent = event as TaskStatusUpdateEvent;
const updateEvent = structuredClone(event as TaskStatusUpdateEvent);
References
  1. Use structuredClone for deep copying objects instead of JSON.parse(JSON.stringify()) to ensure that undefined fields are preserved.

let base: Task | undefined;
if (this.currentTask && this.currentTask.id === updateEvent.taskId) {
this.currentTask.status = updateEvent.status;
if (updateEvent.status.message) {
// Add message to history if not already present
if (
!this.currentTask.history?.find(
(msg) => msg.messageId === updateEvent.status.message!.messageId
)
) {
this.currentTask.history = [
...(this.currentTask.history || []),
updateEvent.status.message,
];
}
}
await this.saveCurrentTask();
base = this.currentTask;
} else if (!this.currentTask && updateEvent.taskId) {
// Potentially an update for a task we haven't seen the 'task' event for yet,
// or we are rehydrating. Attempt to load.
const loaded = await this.taskStore.load(updateEvent.taskId, this.serverCallContext);
if (loaded) {
this.currentTask = loaded;
this.currentTask.status = updateEvent.status;
if (updateEvent.status.message) {
if (
!this.currentTask.history?.find(
(msg) => msg.messageId === updateEvent.status.message!.messageId
)
) {
this.currentTask.history = [
...(this.currentTask.history || []),
updateEvent.status.message,
];
}
}
await this.saveCurrentTask();
} else {
base = await this.taskStore.load(updateEvent.taskId, this.serverCallContext);
if (base === undefined) {
console.warn(
`ResultManager: Received status update for unknown task ${updateEvent.taskId}`
);
}
}
if (base !== undefined) {
// Copy-on-write: build a new Task so any prior reference handed out
// (e.g. via getCurrentTask()) keeps showing its snapshot.
const next: Task = { ...base, status: updateEvent.status };
if (updateEvent.status.message !== undefined) {
const exists = next.history?.some(
(msg) => msg.messageId === updateEvent.status.message?.messageId
);
if (!exists) {
next.history = [...(next.history || []), updateEvent.status.message];
}
}
this.currentTask = next;
await this.saveCurrentTask();
}
// If it's a final status update, the ExecutionEventQueue will stop.
// The final result will be the currentTask.
} else if (event.kind === 'artifact-update') {
const artifactEvent = event as TaskArtifactUpdateEvent;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the status update, the artifactEvent should be deep-cloned to ensure full isolation. Currently, the artifact objects are aliased into the artifacts array (lines 104 and 117), making the internal state susceptible to later mutations by the executor and race conditions during the await at line 89.

Suggested change
const artifactEvent = event as TaskArtifactUpdateEvent;
const artifactEvent = structuredClone(event as TaskArtifactUpdateEvent);
References
  1. Use structuredClone for deep copying objects instead of JSON.parse(JSON.stringify()) to ensure that undefined fields are preserved.

let base: Task | undefined;
if (this.currentTask && this.currentTask.id === artifactEvent.taskId) {
if (!this.currentTask.artifacts) {
this.currentTask.artifacts = [];
base = this.currentTask;
} else if (!this.currentTask && artifactEvent.taskId) {
// No task in memory — try to rehydrate from the store.
base = await this.taskStore.load(artifactEvent.taskId, this.serverCallContext);
if (base === undefined) {
console.warn(
`ResultManager: Received artifact update for unknown task ${artifactEvent.taskId}`
);
}
const existingArtifactIndex = this.currentTask.artifacts.findIndex(
}
if (base !== undefined) {
// Copy-on-write: rebuild the artifacts array so any prior reference
// handed out (e.g. via getCurrentTask()) keeps showing its snapshot.
const artifacts = [...(base.artifacts || [])];
const idx = artifacts.findIndex(
(art) => art.artifactId === artifactEvent.artifact.artifactId
);
if (existingArtifactIndex !== -1) {
if (artifactEvent.append) {
// Basic append logic, assuming parts are compatible
// More sophisticated merging might be needed for specific part types
const existingArtifact = this.currentTask.artifacts[existingArtifactIndex];
existingArtifact.parts.push(...artifactEvent.artifact.parts);
if (artifactEvent.artifact.description)
existingArtifact.description = artifactEvent.artifact.description;
if (artifactEvent.artifact.name) existingArtifact.name = artifactEvent.artifact.name;
if (artifactEvent.artifact.metadata)
existingArtifact.metadata = {
...existingArtifact.metadata,
...artifactEvent.artifact.metadata,
};
} else {
this.currentTask.artifacts[existingArtifactIndex] = artifactEvent.artifact;
}
if (idx === -1) {
artifacts.push(artifactEvent.artifact);
} else if (artifactEvent.append) {
const existing = artifacts[idx];
artifacts[idx] = {
...existing,
parts: [...existing.parts, ...artifactEvent.artifact.parts],
description: artifactEvent.artifact.description ?? existing.description,
name: artifactEvent.artifact.name ?? existing.name,
metadata: artifactEvent.artifact.metadata
? { ...existing.metadata, ...artifactEvent.artifact.metadata }
: existing.metadata,
};
} else {
this.currentTask.artifacts.push(artifactEvent.artifact);
artifacts[idx] = artifactEvent.artifact;
}
this.currentTask = { ...base, artifacts };
await this.saveCurrentTask();
} else if (!this.currentTask && artifactEvent.taskId) {
// Similar to status update, try to load if task not in memory
const loaded = await this.taskStore.load(artifactEvent.taskId, this.serverCallContext);
if (loaded) {
this.currentTask = loaded;
if (!this.currentTask.artifacts) this.currentTask.artifacts = [];
// Apply artifact update logic (as above)
const existingArtifactIndex = this.currentTask.artifacts.findIndex(
(art) => art.artifactId === artifactEvent.artifact.artifactId
);
if (existingArtifactIndex !== -1) {
if (artifactEvent.append) {
this.currentTask.artifacts[existingArtifactIndex].parts.push(
...artifactEvent.artifact.parts
);
} else {
this.currentTask.artifacts[existingArtifactIndex] = artifactEvent.artifact;
}
} else {
this.currentTask.artifacts.push(artifactEvent.artifact);
}
await this.saveCurrentTask();
} else {
console.warn(
`ResultManager: Received artifact update for unknown task ${artifactEvent.taskId}`
);
}
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions test/server/default_request_handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,67 @@ describe('DefaultRequestHandler as A2ARequestHandler', () => {
assert.equal(saveSpy.mock.calls[1][0].status.state, 'completed');
});

it('sendMessage: (non-blocking) returned task reference must remain a snapshot when later events arrive', async () => {
vi.useFakeTimers();

const params: MessageSendParams = {
message: createTestMessage('msg-412', 'Do work'),
configuration: { blocking: false, acceptedOutputModes: [] },
};

const taskId = 'task-412';
const contextId = 'ctx-412';

(mockAgentExecutor as MockAgentExecutor).execute.mockImplementation(async (_ctx, bus) => {
bus.publish({
id: taskId,
contextId,
status: { state: 'submitted' },
kind: 'task',
});

await vi.advanceTimersByTimeAsync(500);

bus.publish({
taskId,
contextId,
kind: 'status-update',
status: {
state: 'failed',
message: {
kind: 'message',
role: 'agent',
messageId: 'agent-fail-msg',
parts: [{ kind: 'text', text: 'Internal Server Error' }],
taskId,
contextId,
},
},
final: true,
});
bus.finished();
});

const immediateResult = await handler.sendMessage(params, serverCallContext);
if ('id' in immediateResult && 'status' in immediateResult) {
assert.equal(
immediateResult.status.state,
'submitted',
"Should hand off in 'submitted' state"
);
// Let the background event loop drain the second event.
await vi.runAllTimersAsync();
// The caller's reference must still represent the handoff snapshot.
assert.equal(
immediateResult.status.state,
'submitted',
'Reference handed to the response layer must not be mutated by later events'
);
} else {
throw new Error('immediateResult is of Message type.');
}
});

it('sendMessage: (non-blocking) should handle failure in event loop after successfull task event', async () => {
vi.useFakeTimers();

Expand Down
104 changes: 104 additions & 0 deletions test/server/result_manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, beforeEach, it, expect } from 'vitest';
import { MockTaskStore } from './mocks/task_store.mock.js';
import { ResultManager } from '../../src/server/index.js';
import { Message, Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent } from '../../src/index.js';

describe('ResultManager isolation', () => {
let mockTaskStore: MockTaskStore;
let resultManager: ResultManager;

const taskId = 't-1';
const contextId = 'c-1';

beforeEach(() => {
mockTaskStore = new MockTaskStore();
resultManager = new ResultManager(mockTaskStore);
});

const createBaseTaskEvent = (overrides: Partial<Task> = {}): Task => ({
kind: 'task',
id: taskId,
contextId,
status: { state: 'submitted', timestamp: '2026-01-01T00:00:00.000Z' },
history: [],
artifacts: [],
...overrides,
});

it('snapshot returned by getCurrentTask() is not mutated by a later status-update', async () => {
await resultManager.processEvent(createBaseTaskEvent());
const snap = resultManager.getCurrentTask();
expect(snap.status.state).toBe('submitted');

const update: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: { state: 'working', timestamp: '2026-01-01T00:00:01.000Z' },
final: false,
};
await resultManager.processEvent(update);

expect(snap.status.state).toBe('submitted');
});

it('snapshot returned by getCurrentTask() is not mutated by a later artifact-update', async () => {
await resultManager.processEvent(createBaseTaskEvent());
const snap = resultManager.getCurrentTask();
expect(snap.artifacts).toEqual([]);

const update: TaskArtifactUpdateEvent = {
kind: 'artifact-update',
taskId,
contextId,
artifact: { artifactId: 'a-1', parts: [{ kind: 'text', text: 'hello' }] },
};
await resultManager.processEvent(update);
expect(snap.artifacts).toEqual([]);
});

it('source task event arrays are not mutated by subsequent updates', async () => {
const userMessage: Message = {
kind: 'message',
role: 'user',
messageId: 'user-m-1',
parts: [{ kind: 'text', text: 'hi' }],
};
resultManager.setContext(userMessage);

const history: Message[] = [];
const artifacts: Task['artifacts'] = [];
const taskEvent = createBaseTaskEvent({ history, artifacts });
await resultManager.processEvent(taskEvent);

const statusUpdate: TaskStatusUpdateEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: 'working',
timestamp: '2026-01-01T00:00:01.000Z',
message: {
kind: 'message',
role: 'agent',
messageId: 'agent-m-1',
parts: [{ kind: 'text', text: 'working' }],
},
},
final: false,
};
await resultManager.processEvent(statusUpdate);

const artifactUpdate: TaskArtifactUpdateEvent = {
kind: 'artifact-update',
taskId,
contextId,
artifact: { artifactId: 'a-1', parts: [{ kind: 'text', text: 'out' }] },
};
await resultManager.processEvent(artifactUpdate);

expect(history).toEqual([]);
expect(artifacts).toEqual([]);
expect(taskEvent.status.state).toBe('submitted');
});
});
Loading