@@ -9,6 +9,18 @@ import { ServerCallContext } from './context.js';
99import { AgentExecutionEvent , assertUnreachableEvent } from './events/execution_event_bus.js' ;
1010import { TaskStore } from './store.js' ;
1111
12+ /**
13+ * Tracks the in-flight task/message state for a single A2A request and
14+ * persists updates to the {@link TaskStore}.
15+ *
16+ * Mutation safety: every external object handed to this class (event
17+ * payloads, user messages) is deep-cloned via `structuredClone` before
18+ * being stored, and every object stored back into the task is likewise a
19+ * fresh clone. This isolates `ResultManager`'s internal state from
20+ * caller-side mutations of the same event objects (and vice versa). The
21+ * `TaskStore.load` / `TaskStore.save` boundary clones independently, so
22+ * this class is safe to combine with stores that share references.
23+ */
1224export class ResultManager {
1325 private readonly taskStore : TaskStore ;
1426 private readonly serverCallContext : ServerCallContext ;
@@ -23,7 +35,9 @@ export class ResultManager {
2335 }
2436
2537 public setContext ( latestUserMessage : Message ) : void {
26- this . latestUserMessage = latestUserMessage ;
38+ // Clone so a caller mutating the message later (or reusing the object
39+ // across calls) can't perturb our internal copy.
40+ this . latestUserMessage = structuredClone ( latestUserMessage ) ;
2741 }
2842
2943 /**
@@ -33,7 +47,9 @@ export class ResultManager {
3347 public async processEvent ( event : AgentExecutionEvent ) : Promise < void > {
3448 switch ( event . kind ) {
3549 case 'message' : {
36- this . finalMessageResult = event . data ;
50+ // Final-result messages may be returned to callers verbatim, so
51+ // store a defensive copy.
52+ this . finalMessageResult = structuredClone ( event . data ) ;
3753 // If a message is received, it's usually the final result,
3854 // but we continue processing to ensure task state (if any) is also saved.
3955 // The ExecutionEventQueue will stop after a message event.
@@ -50,52 +66,66 @@ export class ResultManager {
5066 // Unlike status/artifact updates, receiving a Task event with no
5167 // prior persisted task is the normal create-flow, so we load
5268 // directly rather than going through `ensureTaskLoaded` (which
53- // warns on misses).
54- if ( ! this . currentTask && taskEvent . id ) {
69+ // warns on misses). We also re-load if the in-memory task is for
70+ // a different id, otherwise we'd lose the persisted state for the
71+ // new task id.
72+ if ( ( ! this . currentTask || this . currentTask . id !== taskEvent . id ) && taskEvent . id ) {
5573 const loaded = await this . taskStore . load ( taskEvent . id , this . serverCallContext ) ;
5674 if ( loaded ) {
5775 this . currentTask = loaded ;
76+ } else if ( this . currentTask && this . currentTask . id !== taskEvent . id ) {
77+ // The previously-tracked task is unrelated to the incoming
78+ // event; drop it so we don't accidentally merge across ids.
79+ this . currentTask = undefined ;
5880 }
5981 }
6082 const persistedTask =
6183 this . currentTask && this . currentTask . id === taskEvent . id ? this . currentTask : undefined ;
6284
63- const mergedTask : Task = { ...taskEvent } ;
85+ // Deep-clone the incoming Task so further executor mutations or
86+ // caller-side reuse of the same event object can't leak into our
87+ // state.
88+ const mergedTask : Task = structuredClone ( taskEvent ) ;
6489
6590 if ( persistedTask ) {
6691 // Preserve persisted history when the incoming Task event omits it.
6792 // If the incoming Task event carries its own history, treat it as
6893 // authoritative (the executor is responsible for what gets persisted
6994 // per §3.7).
7095 if ( ( ! mergedTask . history || mergedTask . history . length === 0 ) && persistedTask . history ) {
71- mergedTask . history = [ ... persistedTask . history ] ;
96+ mergedTask . history = structuredClone ( persistedTask . history ) ;
7297 }
7398
7499 // Merge artifacts: keep persisted artifacts and overlay any incoming
75100 // ones (matched by artifactId). Incoming wins for collisions; new
76101 // ones are appended.
77102 mergedTask . artifacts = this . mergeArtifacts ( persistedTask . artifacts , taskEvent . artifacts ) ;
78103
79- // Merge metadata, incoming wins on key collisions.
104+ // Merge metadata, incoming wins on key collisions. structuredClone
105+ // each half so nested values can't be shared with either source.
80106 if ( persistedTask . metadata || taskEvent . metadata ) {
81107 mergedTask . metadata = {
82- ...( persistedTask . metadata ?? { } ) ,
83- ...( taskEvent . metadata ?? { } ) ,
108+ ...structuredClone ( persistedTask . metadata ?? { } ) ,
109+ ...structuredClone ( taskEvent . metadata ?? { } ) ,
84110 } ;
85111 }
86112 }
87113
88114 this . currentTask = mergedTask ;
89115
90116 // Ensure the latest user message is in history if not already present.
117+ // `latestUserMessage` was already cloned in `setContext`, but clone
118+ // again so the same reference can't end up shared between the
119+ // history array and the `latestUserMessage` slot if the same
120+ // `ResultManager` is reused for multiple task events.
91121 if ( this . latestUserMessage ) {
92122 if (
93123 ! this . currentTask . history ?. find (
94124 ( msg ) => msg . messageId === this . latestUserMessage ?. messageId
95125 )
96126 ) {
97127 this . currentTask . history = [
98- this . latestUserMessage ,
128+ structuredClone ( this . latestUserMessage ) ,
99129 ...( this . currentTask . history || [ ] ) ,
100130 ] ;
101131 }
@@ -131,12 +161,14 @@ export class ResultManager {
131161 await this . ensureTaskLoaded ( updateEvent . taskId , 'status update' ) ;
132162
133163 if ( this . currentTask && this . currentTask . id === updateEvent . taskId ) {
134- this . currentTask . status = updateEvent . status ;
164+ // Clone the incoming status (and its nested message) so caller-side
165+ // mutation of the original event payload can't drift our state.
166+ this . currentTask . status = structuredClone ( updateEvent . status ) ;
135167 const update = updateEvent . status ?. message ;
136168 if ( update ) {
137169 // Add message to history if not already present
138170 if ( ! this . currentTask . history ?. find ( ( msg ) => msg . messageId === update . messageId ) ) {
139- this . currentTask . history = [ ...( this . currentTask . history || [ ] ) , update ] ;
171+ this . currentTask . history = [ ...( this . currentTask . history || [ ] ) , structuredClone ( update ) ] ;
140172 }
141173 }
142174 await this . saveCurrentTask ( ) ;
@@ -158,22 +190,27 @@ export class ResultManager {
158190 ) ;
159191 if ( existingArtifactIndex !== - 1 ) {
160192 if ( artifactEvent . append ) {
161- // Basic append logic, assuming parts are compatible
162- // More sophisticated merging might be needed for specific part types
193+ // Basic append logic, assuming parts are compatible.
194+ // Clone incoming parts/metadata so the persisted artifact owns
195+ // its own deep copies and the event payload can be reused
196+ // safely by the executor.
163197 const existingArtifact = this . currentTask . artifacts [ existingArtifactIndex ] ;
164- existingArtifact . parts = [ ...( existingArtifact . parts || [ ] ) , ...( artifact . parts || [ ] ) ] ;
198+ existingArtifact . parts = [
199+ ...( existingArtifact . parts || [ ] ) ,
200+ ...structuredClone ( artifact . parts || [ ] ) ,
201+ ] ;
165202 if ( artifact . description ) existingArtifact . description = artifact . description ;
166203 if ( artifact . name ) existingArtifact . name = artifact . name ;
167204 if ( artifact . metadata )
168205 existingArtifact . metadata = {
169206 ...existingArtifact . metadata ,
170- ...artifact . metadata ,
207+ ...structuredClone ( artifact . metadata ) ,
171208 } ;
172209 } else {
173- this . currentTask . artifacts [ existingArtifactIndex ] = artifact ;
210+ this . currentTask . artifacts [ existingArtifactIndex ] = structuredClone ( artifact ) ;
174211 }
175212 } else {
176- this . currentTask . artifacts . push ( artifact ) ;
213+ this . currentTask . artifacts . push ( structuredClone ( artifact ) ) ;
177214 }
178215 await this . saveCurrentTask ( ) ;
179216 }
@@ -184,28 +221,34 @@ export class ResultManager {
184221 * are retained and overlaid by any incoming artifact with the same id;
185222 * artifacts only present in the incoming list are appended. Order is
186223 * preserved (persisted first, then any newly-introduced incoming artifacts).
224+ *
225+ * Every artifact in the returned array is a fresh deep copy so subsequent
226+ * in-place mutations (e.g. by `applyArtifactUpdate`) can't leak into the
227+ * original `persisted` / `incoming` arrays the caller still holds.
187228 */
188229 private mergeArtifacts (
189230 persisted : Artifact [ ] | undefined ,
190231 incoming : Artifact [ ] | undefined
191232 ) : Artifact [ ] {
192233 if ( ! persisted || persisted . length === 0 ) {
193- return incoming ? [ ... incoming ] : [ ] ;
234+ return incoming ? structuredClone ( incoming ) : [ ] ;
194235 }
195236 if ( ! incoming || incoming . length === 0 ) {
196- return [ ... persisted ] ;
237+ return structuredClone ( persisted ) ;
197238 }
198239
199240 const incomingById = new Map < string , Artifact > ( ) ;
200241 for ( const art of incoming ) {
201242 incomingById . set ( art . artifactId , art ) ;
202243 }
203244
204- const merged : Artifact [ ] = persisted . map ( ( art ) => incomingById . get ( art . artifactId ) ?? art ) ;
245+ const merged : Artifact [ ] = persisted . map ( ( art ) =>
246+ structuredClone ( incomingById . get ( art . artifactId ) ?? art )
247+ ) ;
205248 const seenIds = new Set ( persisted . map ( ( art ) => art . artifactId ) ) ;
206249 for ( const art of incoming ) {
207250 if ( ! seenIds . has ( art . artifactId ) ) {
208- merged . push ( art ) ;
251+ merged . push ( structuredClone ( art ) ) ;
209252 }
210253 }
211254 return merged ;
@@ -220,6 +263,11 @@ export class ResultManager {
220263 /**
221264 * Gets the final result, which could be a Message or a Task.
222265 * This should be called after the event stream has been fully processed.
266+ *
267+ * Returns a reference to the internally-tracked object (not a clone) so
268+ * that downstream callers like `_applyHistoryLengthSemantics` can apply
269+ * in-place edits. Safe because `ResultManager` instances are scoped to a
270+ * single request and discarded immediately after this is called.
223271 * @returns The final Message or the current Task.
224272 */
225273 public getFinalResult ( ) : Message | Task | undefined {
@@ -232,6 +280,9 @@ export class ResultManager {
232280 /**
233281 * Gets the task currently being managed by this ResultManager instance.
234282 * This task could be one that was started with or one created during agent execution.
283+ *
284+ * Returns the internal reference (see {@link getFinalResult} for the
285+ * rationale).
235286 * @returns The current Task or undefined if no task is active.
236287 */
237288 public getCurrentTask ( ) : Task | undefined {
0 commit comments