mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
fix(api): preserve activity phase boundaries
This commit is contained in:
parent
3da5f8d35a
commit
69c117bb1c
4 changed files with 405 additions and 186 deletions
|
|
@ -234,6 +234,50 @@ describe('groupActivityPhases', () => {
|
|||
expect(lastVisibleContentIdx([tool, tool, final, phase as never])).toBe(2);
|
||||
});
|
||||
|
||||
it('groups multiple logical spans when an earlier phase marker arrives late', () => {
|
||||
const first = labelPart({ activity_label: 'Completed the first phase', pending: false });
|
||||
Object.assign(first, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
activity_count: 2,
|
||||
});
|
||||
const second = labelPart({ activity_label: 'Completed the second phase', pending: false });
|
||||
Object.assign(second, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 3,
|
||||
activity_end_index: 6,
|
||||
activity_count: 2,
|
||||
});
|
||||
const boundary = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: 'Substantial result',
|
||||
} as TMessageContentParts;
|
||||
const content = [tool, tool, boundary, tool, first as never, tool, second as never];
|
||||
|
||||
const segments = groupActivityPhases(content);
|
||||
|
||||
expect(segments).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'phase',
|
||||
startIndex: 0,
|
||||
labelIndex: 4,
|
||||
contentIndices: [0, 1],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'content',
|
||||
startIndex: 2,
|
||||
contentIndices: [2],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'phase',
|
||||
startIndex: 3,
|
||||
labelIndex: 6,
|
||||
contentIndices: [3, 5],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a late child label in the phase while leaving final text outside', () => {
|
||||
const child = labelPart({
|
||||
activity_label: 'Recorded the delayed child result',
|
||||
|
|
|
|||
|
|
@ -182,14 +182,49 @@ export function groupActivityPhases(
|
|||
* max permits many parent phases in one long response. */
|
||||
for (const { part, index } of completed) {
|
||||
if (!part) continue;
|
||||
const start = Math.max(
|
||||
cursor,
|
||||
Math.min(index, Math.max(0, part.activity_start_index ?? index)),
|
||||
);
|
||||
const start = Math.min(index, Math.max(0, part.activity_start_index ?? index));
|
||||
const end = Math.max(start, Math.min(index, Math.max(0, part.activity_end_index ?? index)));
|
||||
const adjacent = collect();
|
||||
const phase = collect();
|
||||
const trailing = collect();
|
||||
/** A boundary may resolve after a higher-index parallel activity has
|
||||
* already rendered. Recover that activity from an earlier adjacent
|
||||
* segment so a later phase marker can still claim its declared span. */
|
||||
if (start < cursor) {
|
||||
const recoveredIndices: number[] = [];
|
||||
for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
|
||||
const segment = segments[segmentIndex];
|
||||
if (segment.type !== 'content') {
|
||||
continue;
|
||||
}
|
||||
const retainedContent: Array<TMessageContentParts | undefined> = [];
|
||||
const retainedIndices: number[] = [];
|
||||
for (
|
||||
let childPosition = 0;
|
||||
childPosition < segment.contentIndices.length;
|
||||
childPosition += 1
|
||||
) {
|
||||
const childIndex = segment.contentIndices[childPosition];
|
||||
if (childIndex >= start && childIndex < end) {
|
||||
recoveredIndices.push(childIndex);
|
||||
} else {
|
||||
retainedContent.push(segment.content[childPosition]);
|
||||
retainedIndices.push(childIndex);
|
||||
}
|
||||
}
|
||||
if (retainedIndices.length === 0) {
|
||||
segments.splice(segmentIndex, 1);
|
||||
} else {
|
||||
segment.content = retainedContent;
|
||||
segment.contentIndices = retainedIndices;
|
||||
segment.startIndex = retainedIndices[0];
|
||||
}
|
||||
}
|
||||
recoveredIndices.sort((a, b) => a - b);
|
||||
for (const recoveredIndex of recoveredIndices) {
|
||||
append(phase, recoveredIndex);
|
||||
}
|
||||
}
|
||||
while (definedPosition < definedIndices.length && definedIndices[definedPosition] < index) {
|
||||
const childIndex = definedIndices[definedPosition];
|
||||
definedPosition += 1;
|
||||
|
|
|
|||
|
|
@ -224,6 +224,148 @@ describe('createActivityPhaseWiring', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('retains a later-indexed activity when an interleaved text step crosses a boundary', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } },
|
||||
];
|
||||
const generatedPayloads: GenerateActivityPhasePayload[] = [];
|
||||
const wiring = createActivityPhaseWiring({
|
||||
getContentParts: () => parts,
|
||||
getStepIndex: (stepId) => (stepId === 'interleaved-text' ? 2 : undefined),
|
||||
bumpIndexOffset: jest.fn(),
|
||||
emitLabelEvent: jest.fn(async () => undefined),
|
||||
trackPendingFill: jest.fn(),
|
||||
generatePhase: jest.fn(async (payload: GenerateActivityPhasePayload) => {
|
||||
generatedPayloads.push(payload);
|
||||
return { label: 'Completed one activity phase' };
|
||||
}),
|
||||
});
|
||||
await wiring.hook(batch('tool-1'), new AbortController().signal);
|
||||
await wiring.hook(batch('tool-2'), new AbortController().signal);
|
||||
const handlers = wiring.handlers({
|
||||
[GraphEvents.ON_RUN_STEP]: { handle: jest.fn() },
|
||||
[GraphEvents.ON_MESSAGE_DELTA]: {
|
||||
handle: (_event, data) => {
|
||||
const delta = data as { delta?: { content?: { text?: string } } };
|
||||
parts[2] = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: `${parts[2]?.text ?? ''}${delta.delta?.content?.text ?? ''}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
handlers?.[GraphEvents.ON_RUN_STEP]?.handle(
|
||||
GraphEvents.ON_RUN_STEP,
|
||||
{
|
||||
id: 'interleaved-text',
|
||||
stepDetails: {
|
||||
type: StepTypes.MESSAGE_CREATION,
|
||||
message_creation: { message_id: 'm', content_type: 'text' },
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle(
|
||||
GraphEvents.ON_MESSAGE_DELTA,
|
||||
{ id: 'interleaved-text', delta: { content: { type: ContentTypes.TEXT, text: 'prefix' } } },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
parts[3] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } };
|
||||
await wiring.hook(batch('tool-3'), new AbortController().signal);
|
||||
handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle(
|
||||
GraphEvents.ON_MESSAGE_DELTA,
|
||||
{
|
||||
id: 'interleaved-text',
|
||||
delta: { content: { type: ContentTypes.TEXT, text: 'x'.repeat(SUBSTANTIAL_TEXT_CHARS) } },
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
parts[5] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } };
|
||||
await wiring.hook(batch('tool-4'), new AbortController().signal);
|
||||
|
||||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(generatedPayloads).toHaveLength(2);
|
||||
expect(generatedPayloads.map(({ totalActivityCount }) => totalActivityCount)).toEqual([2, 2]);
|
||||
expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 });
|
||||
expect(parts[6]).toMatchObject({ activity_start_index: 3, activity_count: 2 });
|
||||
});
|
||||
|
||||
it('splits resumed activities around every persisted substantial text boundary', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } },
|
||||
{ type: ContentTypes.TEXT, text: substantialText('First persisted result.') },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } },
|
||||
{ type: ContentTypes.TEXT, text: substantialText('Second persisted result.') },
|
||||
];
|
||||
const generatePhase = jest.fn(async () => ({ label: 'Completed one persisted phase' }));
|
||||
const wiring = createActivityPhaseWiring({
|
||||
initialSnapshot: {
|
||||
version: 1,
|
||||
generated: 0,
|
||||
activityCount: 4,
|
||||
failedActivityCount: 0,
|
||||
partialActivityCount: 0,
|
||||
agentIds: [],
|
||||
activities: [
|
||||
{ startIndex: 0, status: 'success', toolCallIds: ['tool-1'] },
|
||||
{ startIndex: 1, status: 'success', toolCallIds: ['tool-2'] },
|
||||
{ startIndex: 3, status: 'success', toolCallIds: ['tool-3'] },
|
||||
{ startIndex: 4, status: 'success', toolCallIds: ['tool-4'] },
|
||||
],
|
||||
assistantContext: [],
|
||||
pendingReasoning: [],
|
||||
},
|
||||
getContentParts: () => parts,
|
||||
bumpIndexOffset: jest.fn(),
|
||||
emitLabelEvent: jest.fn(async () => undefined),
|
||||
trackPendingFill: jest.fn(),
|
||||
generatePhase,
|
||||
});
|
||||
|
||||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(generatePhase).toHaveBeenCalledTimes(2);
|
||||
expect(parts[6]).toMatchObject({ activity_start_index: 0, activity_end_index: 2 });
|
||||
expect(parts[7]).toMatchObject({ activity_start_index: 3, activity_end_index: 5 });
|
||||
});
|
||||
|
||||
it('keeps a substantial boundary hard when too little preceding work earns a phase', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } },
|
||||
{ type: ContentTypes.TEXT, text: substantialText('Standalone result.') },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } },
|
||||
];
|
||||
const wiring = createActivityPhaseWiring({
|
||||
getContentParts: () => parts,
|
||||
bumpIndexOffset: jest.fn(),
|
||||
emitLabelEvent: jest.fn(async () => undefined),
|
||||
trackPendingFill: jest.fn(),
|
||||
generatePhase: jest.fn(async () => ({ label: 'Completed the later phase' })),
|
||||
});
|
||||
await wiring.hook(batch('tool-1'), new AbortController().signal);
|
||||
await wiring.hook(batch('tool-2'), new AbortController().signal);
|
||||
await wiring.hook(batch('tool-3'), new AbortController().signal);
|
||||
|
||||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[4]).toMatchObject({
|
||||
activity_start_index: 2,
|
||||
activity_end_index: 4,
|
||||
activity_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps interleaved parallel text context keyed to its run step', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } },
|
||||
|
|
@ -934,7 +1076,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
expect(parts[3]).toMatchObject({ activity_end_index: 2, activity_count: 3 });
|
||||
});
|
||||
|
||||
it('resolves index-less pending reasoning before preserving the final-text boundary', async () => {
|
||||
it('keeps index-less reasoning after a preceding substantial-text boundary', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } },
|
||||
|
|
@ -981,10 +1123,10 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[4]).toMatchObject({ activity_end_index: 4, activity_count: 3 });
|
||||
expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 });
|
||||
});
|
||||
|
||||
it('keeps a partially materialized retained batch after the candidate final text', async () => {
|
||||
it('does not force a partially materialized batch across a substantial boundary', async () => {
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'batch-a' } },
|
||||
{ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } },
|
||||
|
|
@ -1022,7 +1164,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 });
|
||||
expect(parts[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('bounds persisted evidence while preserving the full activity count', async () => {
|
||||
|
|
@ -1172,10 +1314,10 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[1]).toMatchObject({ activity_end_index: 0, activity_count: 14 });
|
||||
expect(parts[1]).toMatchObject({ activity_end_index: 0, activity_count: 13 });
|
||||
});
|
||||
|
||||
it('rejects a text boundary when a duplicate reasoning anchor follows it', async () => {
|
||||
it('splits duplicate reasoning anchors by their retained materialized positions', async () => {
|
||||
const duplicate = 'The same reasoning prefix identifies both retained activities.';
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.THINK, think: duplicate },
|
||||
|
|
@ -1222,10 +1364,10 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 });
|
||||
expect(parts[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a text boundary when a duplicate overflow reasoning anchor follows it', async () => {
|
||||
it('keeps duplicate overflow reasoning after a substantial boundary', async () => {
|
||||
const duplicate = 'The overflow reasoning prefix is shared by both positions.';
|
||||
const parts: LooseContentPart[] = [
|
||||
{ type: ContentTypes.THINK, think: duplicate },
|
||||
|
|
@ -1277,7 +1419,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 14 });
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 });
|
||||
});
|
||||
|
||||
it('checks every overflow reasoning anchor when delayed parts materialize out of order', async () => {
|
||||
|
|
@ -1333,7 +1475,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 });
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 });
|
||||
});
|
||||
|
||||
it('retains an earlier overflow ID when delayed tools materialize in reverse order', async () => {
|
||||
|
|
@ -1388,7 +1530,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 });
|
||||
expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 });
|
||||
});
|
||||
|
||||
it('keeps the overflow fallback while a boundary tool remains unresolved', async () => {
|
||||
|
|
@ -1442,7 +1584,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
wiring.complete();
|
||||
await flushDetached();
|
||||
|
||||
expect(parts[2]).toMatchObject({ activity_end_index: 2, activity_count: 15 });
|
||||
expect(parts[2]).toMatchObject({ activity_end_index: 1, activity_count: 13 });
|
||||
});
|
||||
|
||||
it('extends a sparse phase start using only defined boundary slots', async () => {
|
||||
|
|
@ -1477,7 +1619,7 @@ describe('createActivityPhaseWiring', () => {
|
|||
expect(parts[1_000_001]).toMatchObject({ activity_start_index: 0, activity_count: 2 });
|
||||
});
|
||||
|
||||
it('keeps post-cap activities grouped after the last root text', async () => {
|
||||
it('keeps post-cap activities outside the phase before substantial text', async () => {
|
||||
const parts: LooseContentPart[] = [];
|
||||
const generatePhase = jest.fn(async () => ({ label: 'Completed the extended investigation' }));
|
||||
const wiring = createActivityPhaseWiring({
|
||||
|
|
@ -1520,8 +1662,8 @@ describe('createActivityPhaseWiring', () => {
|
|||
expect(generatePhase).toHaveBeenCalledTimes(1);
|
||||
expect(parts[15]).toMatchObject({
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 15,
|
||||
activity_count: 14,
|
||||
activity_end_index: 13,
|
||||
activity_count: 13,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,12 @@ function textValue(value: unknown): string {
|
|||
return typeof nested === 'string' ? nested : '';
|
||||
}
|
||||
|
||||
function isSubstantialText(part: LooseContentPart | null | undefined): boolean {
|
||||
return (
|
||||
part?.type === ContentTypes.TEXT && textValue(part.text).trim().length > SUBSTANTIAL_TEXT_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLabel(value: string | undefined): string {
|
||||
const firstLine = value?.split(/\r?\n/).find((line) => line.trim().length > 0) ?? '';
|
||||
const normalized = firstLine.replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -215,64 +221,33 @@ function findTrackedStart(
|
|||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
activity: TrackedActivity,
|
||||
): number {
|
||||
const materializedStart = findMaterializedActivityStart(parts, activity);
|
||||
return materializedStart ?? Math.min(activity.startIndex, Math.max(0, parts.length - 1));
|
||||
}
|
||||
|
||||
function findMaterializedActivityStart(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
activity: TrackedActivity,
|
||||
): number | undefined {
|
||||
const toolStart = findTrackedToolStart(parts, activity);
|
||||
if (toolStart != null) {
|
||||
return toolStart;
|
||||
}
|
||||
const excerpt = activity.thinkingExcerpts?.[0]?.trim();
|
||||
if (excerpt) {
|
||||
const reasoningStart = findReasoningExcerptStart(parts, excerpt);
|
||||
if (reasoningStart != null) {
|
||||
const reasoningStart = findReasoningStart(parts, excerpt, activity.startIndex);
|
||||
if (
|
||||
parts[reasoningStart]?.type === ContentTypes.THINK &&
|
||||
textValue(parts[reasoningStart]?.think).includes(
|
||||
excerpt.trim().slice(0, REASONING_ANCHOR_CHARS),
|
||||
)
|
||||
) {
|
||||
return reasoningStart;
|
||||
}
|
||||
}
|
||||
return Math.min(activity.startIndex, Math.max(0, parts.length - 1));
|
||||
}
|
||||
|
||||
function findReasoningExcerptStart(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
excerpt: string,
|
||||
): number | undefined {
|
||||
const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS);
|
||||
if (!needle) {
|
||||
return undefined;
|
||||
}
|
||||
for (const index of definedPartIndices(parts)) {
|
||||
if (
|
||||
parts[index]?.type === ContentTypes.THINK &&
|
||||
textValue(parts[index]?.think).includes(needle)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasReasoningExcerptAtOrAfter(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
excerpt: string,
|
||||
minimumIndex: number,
|
||||
): boolean {
|
||||
const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS);
|
||||
if (!needle) {
|
||||
return false;
|
||||
}
|
||||
const indices = definedPartIndices(parts);
|
||||
for (let position = indices.length - 1; position >= 0; position -= 1) {
|
||||
const index = indices[position];
|
||||
if (index < minimumIndex) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
parts[index]?.type === ContentTypes.THINK &&
|
||||
textValue(parts[index]?.think).includes(needle)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type ReasoningAnchorIndex = Map<number, Set<string>>;
|
||||
|
||||
function addReasoningAnchor(
|
||||
|
|
@ -304,31 +279,6 @@ function includesReasoningAnchor(text: string, index: ReasoningAnchorIndex): boo
|
|||
return false;
|
||||
}
|
||||
|
||||
function hasIndexedReasoningAtOrAfter(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
index: ReasoningAnchorIndex,
|
||||
minimumIndex: number,
|
||||
): boolean {
|
||||
if (index.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const indices = definedPartIndices(parts);
|
||||
for (let position = indices.length - 1; position >= 0; position -= 1) {
|
||||
const partIndex = indices[position];
|
||||
if (partIndex < minimumIndex) {
|
||||
break;
|
||||
}
|
||||
const part = parts[partIndex];
|
||||
if (
|
||||
part?.type === ContentTypes.THINK &&
|
||||
includesReasoningAnchor(textValue(part.think), index)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findTrackedToolStart(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
activity: TrackedActivity,
|
||||
|
|
@ -592,6 +542,66 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
contributingAgentIds.clear();
|
||||
};
|
||||
|
||||
const restoreActivities = (
|
||||
retainedActivities: TrackedActivity[],
|
||||
retainedContext: AssistantContextEntry[],
|
||||
retainedActivityCount: number,
|
||||
retainedFailedCount: number,
|
||||
retainedPartialCount: number,
|
||||
retainOverflow: boolean,
|
||||
) => {
|
||||
const retainedStepKinds = new Map<
|
||||
string,
|
||||
{ kind: 'text' | 'think'; phase?: AssistantTextPhase; captureContext?: boolean }
|
||||
>();
|
||||
for (const entry of retainedContext) {
|
||||
if (entry.stepId == null) {
|
||||
continue;
|
||||
}
|
||||
const kind = stepKinds.get(entry.stepId);
|
||||
if (kind != null) {
|
||||
retainedStepKinds.set(entry.stepId, kind);
|
||||
}
|
||||
}
|
||||
const retainedOverflowActivityStartIndex = retainOverflow
|
||||
? overflowActivityStartIndex
|
||||
: undefined;
|
||||
const retainedOverflowToolCallIds = retainOverflow ? [...overflowToolCallIds] : [];
|
||||
const retainedOverflowBoundaryToolCallIds = retainOverflow
|
||||
? [...overflowBoundaryToolCallIds]
|
||||
: [];
|
||||
const retainedOverflowReasoningAnchors = retainOverflow ? [...overflowReasoningAnchors] : [];
|
||||
clear();
|
||||
activities = retainedActivities;
|
||||
assistantContext = retainedContext;
|
||||
activityCount = retainedActivityCount;
|
||||
failedActivityCount = retainedFailedCount;
|
||||
partialActivityCount = retainedPartialCount;
|
||||
overflowActivityStartIndex = retainedOverflowActivityStartIndex;
|
||||
for (const id of retainedOverflowToolCallIds) {
|
||||
overflowToolCallIds.add(id);
|
||||
}
|
||||
for (const id of retainedOverflowBoundaryToolCallIds) {
|
||||
overflowBoundaryToolCallIds.add(id);
|
||||
}
|
||||
for (const anchor of retainedOverflowReasoningAnchors) {
|
||||
addReasoningAnchor(overflowReasoningAnchors, overflowReasoningAnchorIndex, anchor);
|
||||
}
|
||||
for (const activity of retainedActivities) {
|
||||
if (activity.agentId != null) {
|
||||
contributingAgentIds.add(activity.agentId);
|
||||
}
|
||||
}
|
||||
for (const [stepId, kind] of retainedStepKinds) {
|
||||
stepKinds.set(stepId, kind);
|
||||
}
|
||||
for (const entry of retainedContext) {
|
||||
if (entry.stepId != null) {
|
||||
textContextByStepId.set(entry.stepId, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const trackActivity = (activity: TrackedActivity) => {
|
||||
activityCount += 1;
|
||||
if (activity.status === 'error') {
|
||||
|
|
@ -728,17 +738,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
|
||||
const close = (closingTextPhase?: AssistantTextPhase, requestedEndIndex?: number) => {
|
||||
addPendingReasoning();
|
||||
if (generated >= maxPerRun) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (activityCount < MIN_ACTIVITIES) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
generated += 1;
|
||||
const phaseIndex = generated - 1;
|
||||
const currentParts = deps.getContentParts();
|
||||
/** Child-label and phase hooks run independently. A child label can claim
|
||||
* its slot before the corresponding tool part reaches the shared content
|
||||
|
|
@ -746,11 +745,41 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
* from stable tool ids when they are available at close; the backward
|
||||
* scan below claims unresolved leading slots without crossing visible
|
||||
* answer content. */
|
||||
const snapshot = activities.map((activity) => {
|
||||
const resolvedActivities = activities.map((activity) => {
|
||||
const toolStart = findTrackedToolStart(currentParts, activity);
|
||||
return toolStart != null ? { ...activity, startIndex: toolStart } : activity;
|
||||
});
|
||||
const contextSnapshot = assistantContext
|
||||
const closesBeforeBoundary = (activity: TrackedActivity): boolean => {
|
||||
if (requestedEndIndex == null) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
activity.unresolvedToolStartIndex != null &&
|
||||
activity.unresolvedToolStartIndex >= requestedEndIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const materializedStart = findMaterializedActivityStart(currentParts, activity);
|
||||
return materializedStart == null || materializedStart < requestedEndIndex;
|
||||
};
|
||||
const snapshot = resolvedActivities.filter(closesBeforeBoundary);
|
||||
const retainedActivities = resolvedActivities.filter(
|
||||
(activity) => !closesBeforeBoundary(activity),
|
||||
);
|
||||
const closingContext: AssistantContextEntry[] = [];
|
||||
const retainedContext: AssistantContextEntry[] = [];
|
||||
for (const entry of assistantContext) {
|
||||
const stepIndex = entry.stepId != null ? deps.getStepIndex?.(entry.stepId) : undefined;
|
||||
const entryIndex = entry.text.trim()
|
||||
? findTextBoundary(currentParts, entry.text, stepIndex)
|
||||
: stepIndex;
|
||||
if (requestedEndIndex != null && entryIndex != null && entryIndex >= requestedEndIndex) {
|
||||
retainedContext.push(entry);
|
||||
} else {
|
||||
closingContext.push(entry);
|
||||
}
|
||||
}
|
||||
const contextSnapshot = closingContext
|
||||
.map(({ text }) => text)
|
||||
.filter((text) => text.trim().length > 0);
|
||||
/** Completion-finalized phases leave the final root text outside their
|
||||
|
|
@ -771,9 +800,52 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
}
|
||||
}
|
||||
}
|
||||
const totalActivityCount = activityCount;
|
||||
const failedCount = failedActivityCount;
|
||||
const partialCount = partialActivityCount;
|
||||
const overflowCount = Math.max(0, activityCount - resolvedActivities.length);
|
||||
const overflowFailedCount = Math.max(
|
||||
0,
|
||||
failedActivityCount -
|
||||
resolvedActivities.filter((activity) => activity.status === 'error').length,
|
||||
);
|
||||
const overflowPartialCount = Math.max(
|
||||
0,
|
||||
partialActivityCount -
|
||||
resolvedActivities.filter((activity) => activity.status === 'partial').length,
|
||||
);
|
||||
const overflowCloses =
|
||||
overflowCount > 0 &&
|
||||
(requestedEndIndex == null ||
|
||||
(overflowActivityStartIndex != null && overflowActivityStartIndex < requestedEndIndex));
|
||||
const failedCount =
|
||||
snapshot.filter((activity) => activity.status === 'error').length +
|
||||
(overflowCloses ? overflowFailedCount : 0);
|
||||
const partialCount =
|
||||
snapshot.filter((activity) => activity.status === 'partial').length +
|
||||
(overflowCloses ? overflowPartialCount : 0);
|
||||
const totalActivityCount = snapshot.length + (overflowCloses ? overflowCount : 0);
|
||||
const agentIds = overflowCloses
|
||||
? [...contributingAgentIds]
|
||||
: [
|
||||
...new Set(
|
||||
snapshot.flatMap((activity) => (activity.agentId != null ? [activity.agentId] : [])),
|
||||
),
|
||||
];
|
||||
const retainedActivityCount = activityCount - totalActivityCount;
|
||||
const retainedFailedCount = failedActivityCount - failedCount;
|
||||
const retainedPartialCount = partialActivityCount - partialCount;
|
||||
restoreActivities(
|
||||
retainedActivities,
|
||||
retainedContext,
|
||||
retainedActivityCount,
|
||||
retainedFailedCount,
|
||||
retainedPartialCount,
|
||||
overflowCount > 0 && !overflowCloses,
|
||||
);
|
||||
if (generated >= maxPerRun || totalActivityCount < MIN_ACTIVITIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
generated += 1;
|
||||
const phaseIndex = generated - 1;
|
||||
let startIndex = Math.min(...snapshot.map((activity) => activity.startIndex));
|
||||
/** Pull leading commentary/reasoning into the parent card. A prior phase
|
||||
* marker or steer is the only hard UI boundary; plain text can be
|
||||
|
|
@ -789,6 +861,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
if (
|
||||
prior?.type === ContentTypes.STEER ||
|
||||
(prior?.type === ContentTypes.ACTIVITY_LABEL && prior.activity_label_type === 'phase') ||
|
||||
isSubstantialText(prior) ||
|
||||
(prior?.type === ContentTypes.TEXT &&
|
||||
prior.phase === 'final_answer' &&
|
||||
textValue(prior.text).trim().length > 0)
|
||||
|
|
@ -798,7 +871,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
}
|
||||
}
|
||||
startIndex = extendedStartIndex;
|
||||
const agentIds = [...contributingAgentIds];
|
||||
let phaseStatus: 'ok' | 'partial' | 'failed' = 'ok';
|
||||
if (failedCount === totalActivityCount) {
|
||||
phaseStatus = 'failed';
|
||||
|
|
@ -821,7 +893,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
deps.getContentParts().push(part);
|
||||
deps.bumpIndexOffset();
|
||||
void Promise.resolve(deps.emitLabelEvent(index, part)).catch(() => undefined);
|
||||
clear();
|
||||
|
||||
const task = (async () => {
|
||||
let generatedPhase: GeneratedActivityPhase = {};
|
||||
|
|
@ -1029,6 +1100,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
assistantContext = assistantContext.filter((entry) => entry !== contextEntry);
|
||||
textContextByStepId.delete(id ?? '');
|
||||
close(tracked.phase, boundaryIndex);
|
||||
stepKinds.delete(id ?? '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1057,91 +1129,17 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
};
|
||||
|
||||
const complete = () => {
|
||||
let finalTextIndex: number | undefined;
|
||||
const parts = deps.getContentParts();
|
||||
/** A live substantial-text boundary normally closes its phase while
|
||||
* streaming. Reconcile persisted content as a fallback for resume paths
|
||||
* where the original delta crossed the boundary before this wiring was
|
||||
* attached. */
|
||||
const definedIndices = Object.keys(parts);
|
||||
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
|
||||
const index = Number(definedIndices[position]);
|
||||
const part = parts[index];
|
||||
/** Provider phase metadata is only a hint. Size determines whether text
|
||||
* is a standalone result, including MCP responses that retain a lane
|
||||
* id. Later-activity checks reject stale intermediate boundaries. */
|
||||
if (
|
||||
part?.type === ContentTypes.TEXT &&
|
||||
textValue(part.text).trim().length > SUBSTANTIAL_TEXT_CHARS
|
||||
) {
|
||||
finalTextIndex = index;
|
||||
break;
|
||||
for (const index of definedPartIndices(parts)) {
|
||||
if (isSubstantialText(parts[index])) {
|
||||
close(undefined, index);
|
||||
}
|
||||
}
|
||||
if (finalTextIndex != null) {
|
||||
const candidateFinalTextIndex = finalTextIndex;
|
||||
const materializedToolIds = new Set<string>();
|
||||
const trailingToolIds = new Set<string>();
|
||||
for (const key of Object.keys(parts)) {
|
||||
const index = Number(key);
|
||||
const part = parts[index];
|
||||
if (part?.type !== ContentTypes.TOOL_CALL || typeof part.tool_call?.id !== 'string') {
|
||||
continue;
|
||||
}
|
||||
materializedToolIds.add(part.tool_call.id);
|
||||
if (index >= candidateFinalTextIndex) {
|
||||
trailingToolIds.add(part.tool_call.id);
|
||||
}
|
||||
}
|
||||
const hasLaterTrackedActivity = activities.some((activity) => {
|
||||
const toolCallIds = activity.toolCallIds ?? [];
|
||||
if (toolCallIds.some((id) => trailingToolIds.has(id))) {
|
||||
return true;
|
||||
}
|
||||
const reasoningExcerpt = activity.thinkingExcerpts?.[0];
|
||||
if (toolCallIds.length === 0 && reasoningExcerpt) {
|
||||
return hasReasoningExcerptAtOrAfter(parts, reasoningExcerpt, candidateFinalTextIndex);
|
||||
}
|
||||
return (
|
||||
toolCallIds.some((id) => !materializedToolIds.has(id)) &&
|
||||
(activity.unresolvedToolStartIndex ?? activity.startIndex) >= candidateFinalTextIndex
|
||||
);
|
||||
});
|
||||
const overflowIds = [...overflowToolCallIds];
|
||||
const overflowBoundaryIds = [...overflowBoundaryToolCallIds];
|
||||
const hasLaterOverflowActivity =
|
||||
overflowIds.some((id) => trailingToolIds.has(id)) ||
|
||||
hasIndexedReasoningAtOrAfter(
|
||||
parts,
|
||||
overflowReasoningAnchorIndex,
|
||||
candidateFinalTextIndex,
|
||||
) ||
|
||||
(!overflowBoundaryIds.every((id) => materializedToolIds.has(id)) &&
|
||||
overflowActivityStartIndex != null &&
|
||||
overflowActivityStartIndex >= candidateFinalTextIndex);
|
||||
const pendingReasoningAnchors = new Set<string>();
|
||||
const pendingReasoningAnchorIndex: ReasoningAnchorIndex = new Map();
|
||||
let hasLaterPendingReasoningIndex = false;
|
||||
for (const reasoning of pendingReasoning.values()) {
|
||||
/** Empty reasoning reservations are not activities: addPendingReasoning
|
||||
* deliberately drops them. They can still receive a later sparse
|
||||
* index from the SDK, so do not let that placeholder pull a fully
|
||||
* materialized final answer into the completed parent phase. */
|
||||
if (!reasoning.text.trim()) {
|
||||
continue;
|
||||
}
|
||||
hasLaterPendingReasoningIndex ||=
|
||||
reasoning.startIndex != null && reasoning.startIndex >= candidateFinalTextIndex;
|
||||
addReasoningAnchor(pendingReasoningAnchors, pendingReasoningAnchorIndex, reasoning.text);
|
||||
}
|
||||
const hasLaterPendingReasoning =
|
||||
hasLaterPendingReasoningIndex ||
|
||||
hasIndexedReasoningAtOrAfter(parts, pendingReasoningAnchorIndex, candidateFinalTextIndex);
|
||||
if (hasLaterTrackedActivity || hasLaterOverflowActivity || hasLaterPendingReasoning) {
|
||||
finalTextIndex = undefined;
|
||||
}
|
||||
}
|
||||
close(undefined, finalTextIndex);
|
||||
close();
|
||||
};
|
||||
|
||||
return { hook, handlers: wrapHandlers, drop: clear, complete, snapshot };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue