🧭 fix: Re-Anchor Parent Activity Phase Bounds (#14741)

* test: cover parent activity phase finalization

* test(e2e): stabilize parent phase coverage

* fix(agents): reanchor parent activity phase bounds

* fix(agents): preserve delayed tools in activity phases

* test(agents): keep phase slice bounds typed

* fix(agents): preserve sparse activity phase bounds

* test(e2e): read structured phase replies
This commit is contained in:
Danny Avila 2026-08-11 10:16:57 -04:00 committed by GitHub
parent ba29a6c5d6
commit 236ee6c1ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 645 additions and 20 deletions

View file

@ -2287,6 +2287,23 @@ class AgentClient extends BaseClient {
if (!Array.isArray(previousParts) || !Array.isArray(this.contentParts)) {
return;
}
/** Preserve sparse coordinates when completion did not actually reshape
* the content. A phase can reserve a leading hole for a tool part whose
* SDK event lands after the phase closes; scanning retained identities
* in an unchanged array would skip that hole and move the bound past the
* delayed tool before it arrives. */
if (previousParts.length === this.contentParts.length) {
let unchanged = true;
for (let index = 0; index < previousParts.length; index += 1) {
if (previousParts[index] !== this.contentParts[index]) {
unchanged = false;
break;
}
}
if (unchanged) {
return;
}
}
const retainedIndexes = new Map();
for (let index = 0; index < this.contentParts.length; index += 1) {
const part = this.contentParts[index];

View file

@ -187,7 +187,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
expect(phase.activity_start_index).toBe(1);
});
it('rebases phase bounds over sparse content without treating holes as retained parts', () => {
it('rebases phase bounds over reshaped sparse content without retaining holes', () => {
const reasoning = { type: ContentTypes.THINK, think: 'planning' };
const toolCall = toolCallPart('tc-sparse');
const phase = {
@ -203,12 +203,56 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
contentParts[3] = phase;
contentParts[4] = final;
const previousParts = [...contentParts];
const ctx = { options: { agent: {} }, contentParts };
const ctx = { options: { agent: {} }, contentParts: [toolCall, phase, final] };
expect(() =>
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts),
).not.toThrow();
expect(phase.activity_start_index).toBe(2);
expect(phase.activity_start_index).toBe(0);
});
it('preserves a sparse phase reservation when completion does not reshape content', () => {
const firstTool = toolCallPart('tool-1');
const secondTool = toolCallPart('tool-2');
const firstLabel = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Recorded the first result',
tool_call_ids: ['tool-1'],
};
const secondLabel = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Recorded the second result',
tool_call_ids: ['tool-2'],
};
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Verified both results',
activity_label_type: 'phase',
activity_start_index: 0,
};
const final = { type: ContentTypes.TEXT, text: 'Final answer', phase: 'final_answer' };
const contentParts = [];
contentParts[1] = { type: ContentTypes.TEXT, text: '', phase: 'final_answer' };
contentParts[2] = firstLabel;
contentParts[3] = secondTool;
contentParts[4] = secondLabel;
contentParts[5] = phase;
contentParts[6] = final;
const previousParts = [...contentParts];
const ctx = { options: { agent: {} }, contentParts };
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(phase.activity_start_index).toBe(0);
contentParts[0] = firstTool;
const phaseChildren = contentParts.slice(
phase.activity_start_index,
contentParts.indexOf(phase),
);
expect(phaseChildren.map((part) => part?.tool_call?.id).filter(Boolean)).toEqual([
'tool-1',
'tool-2',
]);
});
});