mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🔖 feat: Bound Parent Activity Phases With an Exclusive End Index (#14768)
* 🧭 fix: Finalize Parent Activity Phases at Run Completion * 🧭 fix: Preserve Activity Phase Boundaries * 🎨 fix: Format Activity Phase Boundary Check * 🧭 fix: Ignore Late Label Artifacts at Phase Completion * 🧭 fix: Preserve Logical Activity Phase Membership * 🩹 fix: Narrow Optional Activity Phase Marker * fix activity phase tail boundaries * fix activity phase test lint * fix straddling activity phase batches * preserve activity phase boundaries at scale * fix persisted activity phase final boundary * fix resumed activity phase edge cases * fix sparse activity phase grouping * fix sparse activity phase tail scan * fix resumed activity phase text fallback * fix sparse activity phase completion scans * avoid sparse activity phase runtime scans * stabilize sparse activity phase resumes * support activity phases on current ts target * preserve sparse phase reservations * finalize activity phase boundary handling * avoid sparse phase start scans * fix activity phase final text bounds * tighten activity phase summary boundaries * format activity phase boundary checks * leave final commentary outside activity phases * recognize lane-tagged final activity text * rebase retained activity boundaries on resume * bound activity phase collection work * correct resumed phase activity count * resolve late reasoning before phase completion * preserve lane-tagged final answers * assert durable activity phase bounds in e2e * preserve empty finalized activity phases * ignore empty reasoning at phase completion * format phase completion guard * fix(api): retain overflow reasoning anchors * perf(api): index overflow reasoning anchors * perf(api): skip empty reasoning index scans * fix(api): reconcile completion boundaries efficiently
This commit is contained in:
parent
1a3e2aebcb
commit
df6e15a0de
22 changed files with 2436 additions and 157 deletions
|
|
@ -1306,6 +1306,9 @@ class BaseClient {
|
|||
return {
|
||||
...part,
|
||||
activity_start_index: part.activity_start_index + phaseIndexOffset,
|
||||
...(typeof part.activity_end_index === 'number' && {
|
||||
activity_end_index: part.activity_end_index + phaseIndexOffset,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1883,6 +1883,7 @@ describe('BaseClient', () => {
|
|||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_label: 'Verified deployment health',
|
||||
},
|
||||
];
|
||||
|
|
@ -1890,7 +1891,7 @@ describe('BaseClient', () => {
|
|||
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.TEXT)).toEqual([
|
||||
existing[0],
|
||||
completion[0],
|
||||
{ ...completion[1], activity_start_index: 1 },
|
||||
{ ...completion[1], activity_start_index: 1, activity_end_index: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2248,17 +2248,25 @@ class AgentClient extends BaseClient {
|
|||
*/
|
||||
/**
|
||||
* @deprecated Agent Chain — strip hidden intermediate sequential-agent content
|
||||
* before persistence, keeping only the last part + tool_call parts. Mirrors the
|
||||
* chat path so a HITL resume doesn't persist/emit intermediate outputs the
|
||||
* agent's `hide_sequential_outputs` setting is meant to hide.
|
||||
* before persistence, keeping only the last non-label part + tool_call parts.
|
||||
* Parent activity markers can be appended after the final answer, so physical
|
||||
* array order alone cannot identify the response output that must survive.
|
||||
*/
|
||||
applyHideSequentialOutputsFilter() {
|
||||
if (!this.options.agent?.hide_sequential_outputs || !Array.isArray(this.contentParts)) {
|
||||
return;
|
||||
}
|
||||
let lastOutputIndex = -1;
|
||||
for (let index = this.contentParts.length - 1; index >= 0; index -= 1) {
|
||||
const part = this.contentParts[index];
|
||||
if (part != null && part.type !== ContentTypes.ACTIVITY_LABEL) {
|
||||
lastOutputIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.contentParts = this.contentParts.filter(
|
||||
(part, index) =>
|
||||
index >= this.contentParts.length - 1 ||
|
||||
index === lastOutputIndex ||
|
||||
part.type === ContentTypes.TOOL_CALL ||
|
||||
// Steer parts are user speech, not intermediate agent output — dropping
|
||||
// one would erase the user's words from the persisted turn.
|
||||
|
|
@ -2292,26 +2300,39 @@ class AgentClient extends BaseClient {
|
|||
* 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. */
|
||||
const previousDefinedIndexes = Object.keys(previousParts)
|
||||
.map(Number)
|
||||
.filter((index) => previousParts[index] != null);
|
||||
const currentDefinedIndexes = Object.keys(this.contentParts)
|
||||
.map(Number)
|
||||
.filter((index) => this.contentParts[index] != null);
|
||||
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;
|
||||
}
|
||||
}
|
||||
const unchanged =
|
||||
previousDefinedIndexes.length === currentDefinedIndexes.length &&
|
||||
previousDefinedIndexes.every(
|
||||
(index, position) =>
|
||||
index === currentDefinedIndexes[position] &&
|
||||
previousParts[index] === this.contentParts[index],
|
||||
);
|
||||
if (unchanged) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const retainedIndexes = new Map();
|
||||
for (let index = 0; index < this.contentParts.length; index += 1) {
|
||||
for (const index of currentDefinedIndexes) {
|
||||
const part = this.contentParts[index];
|
||||
if (part != null) {
|
||||
retainedIndexes.set(part, index);
|
||||
}
|
||||
}
|
||||
for (let markerIndex = 0; markerIndex < this.contentParts.length; markerIndex += 1) {
|
||||
const previousIndexesByPart = new Map();
|
||||
for (const index of previousDefinedIndexes) {
|
||||
const part = previousParts[index];
|
||||
if (part != null) {
|
||||
previousIndexesByPart.set(part, index);
|
||||
}
|
||||
}
|
||||
for (const markerIndex of currentDefinedIndexes) {
|
||||
const marker = this.contentParts[markerIndex];
|
||||
if (
|
||||
marker?.type !== ContentTypes.ACTIVITY_LABEL ||
|
||||
|
|
@ -2320,26 +2341,65 @@ class AgentClient extends BaseClient {
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
const previousMarkerIndex = previousParts.indexOf(marker);
|
||||
if (previousMarkerIndex < 0) {
|
||||
const previousMarkerIndex = previousIndexesByPart.get(marker);
|
||||
if (previousMarkerIndex == null) {
|
||||
continue;
|
||||
}
|
||||
const previousStartIndex = Math.min(
|
||||
previousMarkerIndex,
|
||||
Math.max(0, marker.activity_start_index),
|
||||
);
|
||||
const hasExplicitEnd = typeof marker.activity_end_index === 'number';
|
||||
const previousEndIndex = hasExplicitEnd
|
||||
? Math.max(previousStartIndex, Math.min(previousMarkerIndex, marker.activity_end_index))
|
||||
: previousMarkerIndex;
|
||||
let nextStartIndex = markerIndex;
|
||||
for (let index = previousStartIndex; index < previousMarkerIndex; index += 1) {
|
||||
let nextEndIndex = markerIndex;
|
||||
let foundRetainedPart = false;
|
||||
for (const index of previousDefinedIndexes) {
|
||||
if (index < previousStartIndex || index >= previousEndIndex) {
|
||||
continue;
|
||||
}
|
||||
const retainedIndex = retainedIndexes.get(previousParts[index]);
|
||||
if (retainedIndex != null && retainedIndex < markerIndex) {
|
||||
nextStartIndex = retainedIndex;
|
||||
break;
|
||||
if (!foundRetainedPart) {
|
||||
nextStartIndex = retainedIndex;
|
||||
nextEndIndex = retainedIndex + 1;
|
||||
foundRetainedPart = true;
|
||||
} else {
|
||||
nextStartIndex = Math.min(nextStartIndex, retainedIndex);
|
||||
nextEndIndex = Math.min(markerIndex, Math.max(nextEndIndex, retainedIndex + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundRetainedPart && hasExplicitEnd) {
|
||||
for (const index of previousDefinedIndexes) {
|
||||
if (index < previousEndIndex || index >= previousMarkerIndex) {
|
||||
continue;
|
||||
}
|
||||
const retainedIndex = retainedIndexes.get(previousParts[index]);
|
||||
if (retainedIndex != null && retainedIndex < markerIndex) {
|
||||
nextStartIndex = retainedIndex;
|
||||
nextEndIndex = retainedIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
marker.activity_start_index = nextStartIndex;
|
||||
if (hasExplicitEnd) {
|
||||
marker.activity_end_index = Math.max(nextStartIndex, nextEndIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize only a completed root run; HITL interruptions retain their snapshot for resume. */
|
||||
completeActivityPhase(run, activityPhase) {
|
||||
if (typeof run?.getInterrupt === 'function' && run.getInterrupt()?.payload) {
|
||||
return;
|
||||
}
|
||||
activityPhase?.complete?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface any human-in-the-loop interrupt the SDK captured during the most
|
||||
* recent `processStream` / `resume`. When the run paused for tool approval (or
|
||||
|
|
@ -2904,6 +2964,7 @@ class AgentClient extends BaseClient {
|
|||
[Callback.TOOL_ERROR]: logToolError,
|
||||
},
|
||||
});
|
||||
this.completeActivityPhase(run, activityPhase);
|
||||
|
||||
// HITL: if the run paused for tool approval, mark the job
|
||||
// `requires_action` + emit the prompt and leave the turn unfinalized
|
||||
|
|
@ -3278,6 +3339,7 @@ class AgentClient extends BaseClient {
|
|||
{ callbacks: { [Callback.TOOL_ERROR]: logToolError } },
|
||||
commandOptions,
|
||||
);
|
||||
this.completeActivityPhase(run, activityPhase);
|
||||
|
||||
config.signal = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
const textPart = (text) => ({ type: ContentTypes.TEXT, text });
|
||||
const toolCallPart = (id) => ({ type: ContentTypes.TOOL_CALL, tool_call: { id } });
|
||||
|
||||
it('keeps only the last part + tool_call parts when hide_sequential_outputs is on', () => {
|
||||
it('keeps only the last non-label part + tool_call parts when filtering is on', () => {
|
||||
const ctx = {
|
||||
options: { agent: { hide_sequential_outputs: true } },
|
||||
contentParts: [
|
||||
|
|
@ -156,6 +156,58 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
expect(ctx.contentParts).toEqual([toolCallPart('tc1'), textPart('final')]);
|
||||
});
|
||||
|
||||
it('keeps the final text when a parent phase marker is appended after it', () => {
|
||||
const tool = toolCallPart('tc1');
|
||||
const final = textPart('final');
|
||||
const phase = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
activity_label: 'Completed the investigation',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
};
|
||||
const ctx = {
|
||||
options: { agent: { hide_sequential_outputs: true } },
|
||||
contentParts: [textPart('intermediate'), tool, final, phase],
|
||||
};
|
||||
const previousParts = [...ctx.contentParts];
|
||||
|
||||
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
|
||||
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
|
||||
|
||||
expect(ctx.contentParts).toEqual([tool, final, phase]);
|
||||
expect(phase.activity_start_index).toBe(0);
|
||||
expect(phase.activity_end_index).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps an appended phase before the final text when all phase children are filtered', () => {
|
||||
const final = textPart('final');
|
||||
const phase = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
activity_label: 'Completed both reasoning activities',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
};
|
||||
const ctx = {
|
||||
options: { agent: { hide_sequential_outputs: true } },
|
||||
contentParts: [
|
||||
{ type: ContentTypes.THINK, think: 'first' },
|
||||
{ type: ContentTypes.THINK, think: 'second' },
|
||||
final,
|
||||
phase,
|
||||
],
|
||||
};
|
||||
const previousParts = [...ctx.contentParts];
|
||||
|
||||
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
|
||||
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
|
||||
|
||||
expect(ctx.contentParts).toEqual([final, phase]);
|
||||
expect(phase.activity_start_index).toBe(0);
|
||||
expect(phase.activity_end_index).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op when hide_sequential_outputs is off', () => {
|
||||
const parts = [textPart('a'), textPart('b')];
|
||||
const ctx = { options: { agent: { hide_sequential_outputs: false } }, contentParts: parts };
|
||||
|
|
@ -171,6 +223,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
activity_label: 'Resolved the session issue',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
};
|
||||
const final = textPart('final');
|
||||
const previousParts = [reasoning, activityTool, phase, final];
|
||||
|
|
@ -185,6 +238,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
|
||||
expect(ctx.contentParts).toEqual([skillCard, activityTool, phase, final]);
|
||||
expect(phase.activity_start_index).toBe(1);
|
||||
expect(phase.activity_end_index).toBe(2);
|
||||
});
|
||||
|
||||
it('rebases phase bounds over reshaped sparse content without retaining holes', () => {
|
||||
|
|
@ -211,6 +265,26 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
expect(phase.activity_start_index).toBe(0);
|
||||
});
|
||||
|
||||
it('rebases explicit bounds using only defined sparse slots', () => {
|
||||
const toolCall = toolCallPart('tc-large-sparse');
|
||||
const phase = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
activity_label: 'Searched the sparse transcript',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 5,
|
||||
activity_end_index: 999_999,
|
||||
};
|
||||
const previousParts = [];
|
||||
previousParts[5] = toolCall;
|
||||
previousParts[999_999] = phase;
|
||||
const ctx = { options: { agent: {} }, contentParts: [toolCall, phase] };
|
||||
|
||||
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
|
||||
|
||||
expect(phase.activity_start_index).toBe(0);
|
||||
expect(phase.activity_end_index).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves a sparse phase reservation when completion does not reshape content', () => {
|
||||
const firstTool = toolCallPart('tool-1');
|
||||
const secondTool = toolCallPart('tool-2');
|
||||
|
|
@ -256,6 +330,28 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - activity phase completion', () => {
|
||||
it('completes an uninterrupted root run', () => {
|
||||
const complete = jest.fn();
|
||||
AgentClient.prototype.completeActivityPhase.call(
|
||||
{},
|
||||
{ getInterrupt: () => undefined },
|
||||
{ complete },
|
||||
);
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retains phase state when the root run pauses for HITL', () => {
|
||||
const complete = jest.fn();
|
||||
AgentClient.prototype.completeActivityPhase.call(
|
||||
{},
|
||||
{ getInterrupt: () => ({ payload: { type: 'tool_approval' } }) },
|
||||
{ complete },
|
||||
);
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentClient - startup telemetry', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { cn } from '~/utils';
|
|||
type ActivityPhasePart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
|
||||
activity_label_type?: 'phase';
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
};
|
||||
|
||||
export default function ActivityPhaseGroup({
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ type ContentPartsProps = {
|
|||
nestedActivityPhase?: boolean;
|
||||
/** Absolute transcript index represented by `content[0]` in a phase slice. */
|
||||
contentIndexOffset?: number;
|
||||
/** Absolute transcript index for each compacted sparse segment entry. */
|
||||
contentIndices?: ReadonlyArray<number>;
|
||||
/** Message-wide steer attribution retained across nested phase segments. */
|
||||
resumeAuthors?: ReadonlyMap<number, string | undefined>;
|
||||
/** Message-wide tool-group expansion overrides retained across phase slices. */
|
||||
|
|
@ -184,6 +186,7 @@ const ContentParts = memo(function ContentParts({
|
|||
createdAt,
|
||||
nestedActivityPhase = false,
|
||||
contentIndexOffset = 0,
|
||||
contentIndices,
|
||||
resumeAuthors,
|
||||
toolGroupExpansionState,
|
||||
}: ContentPartsProps) {
|
||||
|
|
@ -200,6 +203,17 @@ const ContentParts = memo(function ContentParts({
|
|||
fallbackScopeRef.current.messageId = messageId;
|
||||
}
|
||||
const fallbackScope = fallbackScopeRef.current.scope;
|
||||
const localIndexByAbsolute = useMemo(
|
||||
() =>
|
||||
contentIndices == null
|
||||
? undefined
|
||||
: new Map(contentIndices.map((absoluteIndex, localIndex) => [absoluteIndex, localIndex])),
|
||||
[contentIndices],
|
||||
);
|
||||
const absoluteIndexAt = useCallback(
|
||||
(localIndex: number) => contentIndices?.[localIndex] ?? localIndex + contentIndexOffset,
|
||||
[contentIndexOffset, contentIndices],
|
||||
);
|
||||
|
||||
const handleGroupExpansionChange = useCallback(
|
||||
(groupId: string, state: ToolCallGroupExpansionState) => {
|
||||
|
|
@ -271,7 +285,7 @@ const ContentParts = memo(function ContentParts({
|
|||
|
||||
const renderPart = useCallback(
|
||||
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
||||
const localIdx = idx - contentIndexOffset;
|
||||
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
|
||||
return (
|
||||
<PartWithContext
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
|
|
@ -296,6 +310,7 @@ const ContentParts = memo(function ContentParts({
|
|||
attachmentMap,
|
||||
content,
|
||||
contentIndexOffset,
|
||||
localIndexByAbsolute,
|
||||
conversationId,
|
||||
effectiveIsSubmitting,
|
||||
isCreatedByUser,
|
||||
|
|
@ -307,7 +322,7 @@ const ContentParts = memo(function ContentParts({
|
|||
|
||||
const renderGroupedPart = useCallback(
|
||||
(part: TMessageContentParts, idx: number, isLastPart: boolean, onToolExpand?: () => void) => {
|
||||
const localIdx = idx - contentIndexOffset;
|
||||
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
|
||||
return (
|
||||
<PartWithContext
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
|
|
@ -334,6 +349,7 @@ const ContentParts = memo(function ContentParts({
|
|||
attachmentMap,
|
||||
content,
|
||||
contentIndexOffset,
|
||||
localIndexByAbsolute,
|
||||
conversationId,
|
||||
effectiveIsSubmitting,
|
||||
isCreatedByUser,
|
||||
|
|
@ -362,7 +378,7 @@ const ContentParts = memo(function ContentParts({
|
|||
if (!part) {
|
||||
return;
|
||||
}
|
||||
const idx = localIdx + contentIndexOffset;
|
||||
const idx = absoluteIndexAt(localIdx);
|
||||
if (prevType === ContentTypes.STEER && part.type !== ContentTypes.STEER) {
|
||||
authors.set(idx, activeAgentId);
|
||||
}
|
||||
|
|
@ -373,7 +389,7 @@ const ContentParts = memo(function ContentParts({
|
|||
parts.push({ part, idx });
|
||||
});
|
||||
return { sequentialParts: parts, detectedResumeAuthors: authors };
|
||||
}, [content, contentIndexOffset]);
|
||||
}, [absoluteIndexAt, content]);
|
||||
const postSteerAuthors = resumeAuthors ?? detectedResumeAuthors;
|
||||
|
||||
const groupedParts = useMemo(
|
||||
|
|
@ -423,7 +439,7 @@ const ContentParts = memo(function ContentParts({
|
|||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
const idx = localIdx + contentIndexOffset;
|
||||
const idx = absoluteIndexAt(localIdx);
|
||||
const isTextPart =
|
||||
part?.type === ContentTypes.TEXT ||
|
||||
typeof (part as unknown as Agents.MessageContentText)?.text === 'string';
|
||||
|
|
@ -460,13 +476,13 @@ const ContentParts = memo(function ContentParts({
|
|||
if (phaseSegments != null) {
|
||||
const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []);
|
||||
const globalLastContentIdx =
|
||||
relativeGlobalLastContentIdx < 0 ? -1 : relativeGlobalLastContentIdx + contentIndexOffset;
|
||||
relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx);
|
||||
const renderSegment = (
|
||||
segmentContent: Array<TMessageContentParts | undefined>,
|
||||
segmentStartIndex: number,
|
||||
segmentIndices: ReadonlyArray<number>,
|
||||
key: string,
|
||||
) => {
|
||||
const localLastContentIdx = globalLastContentIdx - segmentStartIndex;
|
||||
return (
|
||||
<ContentParts
|
||||
key={key}
|
||||
|
|
@ -478,11 +494,12 @@ const ContentParts = memo(function ContentParts({
|
|||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={isLast && segmentContent[localLastContentIdx] != null}
|
||||
isLast={isLast && segmentIndices.includes(globalLastContentIdx)}
|
||||
isSubmitting={isSubmitting}
|
||||
isLatestMessage={isLatestMessage}
|
||||
nestedActivityPhase
|
||||
contentIndexOffset={segmentStartIndex}
|
||||
contentIndices={segmentIndices}
|
||||
resumeAuthors={postSteerAuthors}
|
||||
toolGroupExpansionState={expansionState}
|
||||
/>
|
||||
|
|
@ -506,19 +523,21 @@ const ContentParts = memo(function ContentParts({
|
|||
showCursor={
|
||||
isLast &&
|
||||
effectiveIsSubmitting &&
|
||||
segment.labelIndex + contentIndexOffset === globalLastContentIdx
|
||||
absoluteIndexAt(segment.labelIndex) === globalLastContentIdx
|
||||
}
|
||||
>
|
||||
{renderSegment(
|
||||
segment.content,
|
||||
segment.startIndex + contentIndexOffset,
|
||||
absoluteIndexAt(segment.startIndex),
|
||||
segment.contentIndices.map(absoluteIndexAt),
|
||||
`phase-content-${index}`,
|
||||
)}
|
||||
</ActivityPhaseGroup>
|
||||
) : (
|
||||
renderSegment(
|
||||
segment.content,
|
||||
segment.startIndex + contentIndexOffset,
|
||||
absoluteIndexAt(segment.startIndex),
|
||||
segment.contentIndices.map(absoluteIndexAt),
|
||||
`phase-adjacent-${index}`,
|
||||
)
|
||||
),
|
||||
|
|
@ -534,8 +553,7 @@ const ContentParts = memo(function ContentParts({
|
|||
* counting one as last would strip the streaming cursor from the last
|
||||
* VISIBLE part until the next delta. */
|
||||
const relativeLastContentIdx = lastVisibleContentIdx(safeContent);
|
||||
const lastContentIdx =
|
||||
relativeLastContentIdx < 0 ? -1 : relativeLastContentIdx + contentIndexOffset;
|
||||
const lastContentIdx = relativeLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeLastContentIdx);
|
||||
|
||||
// Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata)
|
||||
const hasParallelContent = safeContent.some((part) => part?.groupId != null);
|
||||
|
|
@ -555,6 +573,7 @@ const ContentParts = memo(function ContentParts({
|
|||
renderResumeAttribution={renderResumeAttribution}
|
||||
showDecorations={!nestedActivityPhase}
|
||||
contentIndexOffset={contentIndexOffset}
|
||||
contentIndices={contentIndices}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type ParallelSection = {
|
|||
export function groupParallelContent(
|
||||
content: Array<TMessageContentParts | undefined> | undefined,
|
||||
contentIndexOffset = 0,
|
||||
contentIndices?: ReadonlyArray<number>,
|
||||
): { parallelSections: ParallelSection[]; sequentialParts: PartWithIndex[] } {
|
||||
if (!content) {
|
||||
return { parallelSections: [], sequentialParts: [] };
|
||||
|
|
@ -50,7 +51,7 @@ export function groupParallelContent(
|
|||
if (!part) {
|
||||
return;
|
||||
}
|
||||
const idx = localIdx + contentIndexOffset;
|
||||
const idx = contentIndices?.[localIdx] ?? localIdx + contentIndexOffset;
|
||||
|
||||
// Read metadata directly from content part (TMessageContentParts includes ContentMetadata)
|
||||
const { groupId } = part;
|
||||
|
|
@ -230,6 +231,8 @@ type ParallelContentRendererProps = {
|
|||
showDecorations?: boolean;
|
||||
/** Absolute transcript index represented by `content[0]` in a phase slice. */
|
||||
contentIndexOffset?: number;
|
||||
/** Absolute transcript index for each compacted sparse segment entry. */
|
||||
contentIndices?: ReadonlyArray<number>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -248,10 +251,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
|||
renderResumeAttribution,
|
||||
showDecorations = true,
|
||||
contentIndexOffset = 0,
|
||||
contentIndices,
|
||||
}: ParallelContentRendererProps) {
|
||||
const { parallelSections, sequentialParts } = useMemo(
|
||||
() => groupParallelContent(content, contentIndexOffset),
|
||||
[content, contentIndexOffset],
|
||||
() => groupParallelContent(content, contentIndexOffset, contentIndices),
|
||||
[content, contentIndexOffset, contentIndices],
|
||||
);
|
||||
|
||||
/** Same walk-back as `ContentParts`: a trailing BLANK label reservation is
|
||||
|
|
@ -259,7 +263,9 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
|||
* rendered part with the last-part cursor until the label fills. */
|
||||
const relativeLastContentIdx = lastVisibleContentIdx(content);
|
||||
const lastContentIdx =
|
||||
relativeLastContentIdx < 0 ? -1 : relativeLastContentIdx + contentIndexOffset;
|
||||
relativeLastContentIdx < 0
|
||||
? -1
|
||||
: (contentIndices?.[relativeLastContentIdx] ?? relativeLastContentIdx + contentIndexOffset);
|
||||
|
||||
// Split sequential parts into before/after parallel sections
|
||||
const { before, after } = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -327,6 +327,33 @@ describe('ContentParts — post-steer author re-attribution', () => {
|
|||
});
|
||||
|
||||
describe('ContentParts — activity phase state', () => {
|
||||
it('renders a completion-appended parent before the final root text', () => {
|
||||
const tool = {
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: { id: 'tool-1', name: 'search', args: {}, output: 'done' },
|
||||
} as unknown as TMessageContentParts;
|
||||
const final = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: 'Final answer',
|
||||
} as unknown as TMessageContentParts;
|
||||
const phase = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
[ContentTypes.ACTIVITY_LABEL]: 'Completed the full investigation',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
activity_count: 2,
|
||||
pending: false,
|
||||
} as unknown as TMessageContentParts;
|
||||
|
||||
render(<ContentParts {...baseProps} content={[tool, tool, final, phase]} />);
|
||||
|
||||
const parent = screen.getByTestId('activity-phase-group');
|
||||
const finalPart = screen.getByTestId(`real-part-${ContentTypes.TEXT}`);
|
||||
expect(parent.compareDocumentPosition(finalPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(finalPart).toHaveAttribute('data-index', '2');
|
||||
});
|
||||
|
||||
it('keeps a streaming cursor when a completed phase marker is the visible tail', () => {
|
||||
const phase = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
|
|
|
|||
|
|
@ -20,4 +20,24 @@ describe('groupParallelContent', () => {
|
|||
expect(grouped.sequentialParts).toEqual([{ part: sequential, idx: 4 }]);
|
||||
expect(grouped.parallelSections[0]?.columns[0]?.parts).toEqual([{ part: parallel, idx: 5 }]);
|
||||
});
|
||||
|
||||
test('preserves absolute indices for a compacted sparse phase segment', () => {
|
||||
const sequential = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: 'before lanes',
|
||||
} as unknown as TMessageContentParts;
|
||||
const parallel = {
|
||||
type: ContentTypes.TEXT,
|
||||
text: 'lane result',
|
||||
groupId: 1,
|
||||
agentId: 'agent-1',
|
||||
} as unknown as TMessageContentParts;
|
||||
|
||||
const grouped = groupParallelContent([sequential, parallel], 0, [2, 10_000]);
|
||||
|
||||
expect(grouped.sequentialParts).toEqual([{ part: sequential, idx: 2 }]);
|
||||
expect(grouped.parallelSections[0]?.columns[0]?.parts).toEqual([
|
||||
{ part: parallel, idx: 10_000 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3413,6 +3413,42 @@ describe('useStepHandler', () => {
|
|||
const response = currentMessages.find((m) => !m.isCreatedByUser);
|
||||
expect(response?.content?.[2]).toMatchObject({ [ContentTypes.TEXT]: 'streamed' });
|
||||
expect(response?.content?.[0]).toMatchObject({ [ContentTypes.TEXT]: 'kept a' });
|
||||
expect(
|
||||
(submission as { editPrefixFirstPartFolded?: boolean }).editPrefixFirstPartFolded,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records when the first completion part actually folds into the retained tail', () => {
|
||||
const submission = createSubmission({
|
||||
editedContent: { index: 0, type: ContentTypes.TEXT },
|
||||
initialResponse: createResponseMessage({ content: [textPart('kept'), textPart('tail')] }),
|
||||
} as never);
|
||||
(submission as { editPrefixLength?: number }).editPrefixLength = 2;
|
||||
const responseMessage = submission.initialResponse as TMessage;
|
||||
let currentMessages: TMessage[] = [responseMessage];
|
||||
mockGetMessages.mockImplementation(() => currentMessages);
|
||||
mockSetMessages.mockImplementation((messages: TMessage[]) => {
|
||||
currentMessages = messages;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStepHandler(createHookParams()));
|
||||
act(() => {
|
||||
result.current.stepHandler(
|
||||
{
|
||||
event: StepEvents.ON_RUN_STEP,
|
||||
data: createRunStep({ index: 0, runId: responseMessage.messageId }),
|
||||
},
|
||||
submission,
|
||||
);
|
||||
result.current.stepHandler(
|
||||
{ event: StepEvents.ON_MESSAGE_DELTA, data: createMessageDelta('step-1', ' continued') },
|
||||
submission,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
(submission as { editPrefixFirstPartFolded?: boolean }).editPrefixFirstPartFolded,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not merge final-answer text into a retained commentary phase', () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
resolveRunEndTarget,
|
||||
findSteerMessageIndex,
|
||||
applyActivityLabelPart,
|
||||
offsetActivityPhaseBoundary,
|
||||
findActivityLabelMessageIndex,
|
||||
appendAppliedSteerIds,
|
||||
collectAppliedSteerIds,
|
||||
|
|
@ -724,6 +725,7 @@ export default function useResumableSSE(
|
|||
* `editPrefixLength` must no longer be applied — by run steps or labels.
|
||||
*/
|
||||
const editPrefixClearedRef = useRef(false);
|
||||
const editPrefixFirstPartFoldedRef = useRef(false);
|
||||
/** Generation the cleared-prefix state above belongs to, so it is dropped
|
||||
* when a new generation starts rather than when a subscribe happens to be
|
||||
* live. Keyed by response message id — the stream id is the conversation
|
||||
|
|
@ -1056,13 +1058,15 @@ export default function useResumableSSE(
|
|||
* the non-resumable transport, the submission passes through untouched.
|
||||
*/
|
||||
const stepHandler = useCallback(
|
||||
(...[event, submission]: Parameters<typeof rawStepHandler>) =>
|
||||
rawStepHandler(
|
||||
event,
|
||||
editPrefixClearedRef.current
|
||||
? ({ ...submission, editPrefixCleared: true } as EventSubmission)
|
||||
: submission,
|
||||
),
|
||||
(...[event, submission]: Parameters<typeof rawStepHandler>) => {
|
||||
const eventSubmission = editPrefixClearedRef.current
|
||||
? ({ ...submission, editPrefixCleared: true } as EventSubmission)
|
||||
: submission;
|
||||
rawStepHandler(event, eventSubmission);
|
||||
if (eventSubmission.editPrefixFirstPartFolded === true) {
|
||||
editPrefixFirstPartFoldedRef.current = true;
|
||||
}
|
||||
},
|
||||
[rawStepHandler],
|
||||
);
|
||||
|
||||
|
|
@ -1165,6 +1169,7 @@ export default function useResumableSSE(
|
|||
if (prefixStateGenerationIdRef.current !== generationId) {
|
||||
prefixStateGenerationIdRef.current = generationId;
|
||||
editPrefixClearedRef.current = false;
|
||||
editPrefixFirstPartFoldedRef.current = false;
|
||||
}
|
||||
let { userMessage } = currentSubmission;
|
||||
let textIndex: number | null = null;
|
||||
|
|
@ -1356,27 +1361,29 @@ export default function useResumableSSE(
|
|||
const phasePart = event.part as TActivityLabelEvent['part'] & {
|
||||
activity_label_type?: 'phase';
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
};
|
||||
let offsetEvent = event;
|
||||
if (prefixLength > 0) {
|
||||
let offsetPart: TActivityLabelEvent['part'] & {
|
||||
activity_label_type?: 'phase';
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
} = phasePart;
|
||||
if (
|
||||
phasePart.activity_label_type === 'phase' &&
|
||||
typeof phasePart.activity_start_index === 'number'
|
||||
) {
|
||||
let activityStartIndex = phasePart.activity_start_index + prefixLength;
|
||||
const foldedFirstPart = editPrefixFirstPartFoldedRef.current;
|
||||
const targetContent = messages[index]?.content;
|
||||
/** The first completion text/think part can merge into the
|
||||
* retained edit tail at prefixLength - 1. Tool/nonmatching starts
|
||||
* occupy the ordinary +prefix slot, so only fold back across the
|
||||
* recognizable empty merge slot. */
|
||||
/** The step handler records an actual server-index-zero text/think
|
||||
* merge. An empty +prefix slot is insufficient evidence because
|
||||
* a delayed tool may not have materialized there yet. */
|
||||
if (
|
||||
phasePart.activity_start_index === 0 &&
|
||||
activityStartIndex > 0 &&
|
||||
targetContent?.[activityStartIndex] == null &&
|
||||
foldedFirstPart &&
|
||||
targetContent?.[activityStartIndex - 1] != null
|
||||
) {
|
||||
activityStartIndex -= 1;
|
||||
|
|
@ -1384,6 +1391,13 @@ export default function useResumableSSE(
|
|||
offsetPart = {
|
||||
...phasePart,
|
||||
activity_start_index: activityStartIndex,
|
||||
...(typeof phasePart.activity_end_index === 'number' && {
|
||||
activity_end_index: offsetActivityPhaseBoundary(
|
||||
phasePart.activity_end_index,
|
||||
prefixLength,
|
||||
foldedFirstPart,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
offsetEvent = {
|
||||
|
|
|
|||
|
|
@ -984,6 +984,14 @@ export default function useStepHandler({
|
|||
updatedResponse.content,
|
||||
phase,
|
||||
);
|
||||
if (
|
||||
submission != null &&
|
||||
runStep.index === 0 &&
|
||||
editPrefixOffset > 0 &&
|
||||
currentIndex === editPrefixOffset - 1
|
||||
) {
|
||||
submission.editPrefixFirstPartFolded = true;
|
||||
}
|
||||
updatedResponse = updateContent(
|
||||
updatedResponse,
|
||||
currentIndex,
|
||||
|
|
@ -1034,6 +1042,14 @@ export default function useStepHandler({
|
|||
contentPart.type || '',
|
||||
updatedResponse.content,
|
||||
);
|
||||
if (
|
||||
submission != null &&
|
||||
runStep.index === 0 &&
|
||||
editPrefixOffset > 0 &&
|
||||
currentIndex === editPrefixOffset - 1
|
||||
) {
|
||||
submission.editPrefixFirstPartFolded = true;
|
||||
}
|
||||
updatedResponse = updateContent(
|
||||
updatedResponse,
|
||||
currentIndex,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
applyActivityLabelPart,
|
||||
groupActivityPhases,
|
||||
lastVisibleContentIdx,
|
||||
offsetActivityPhaseBoundary,
|
||||
} from '../activityLabels';
|
||||
|
||||
const buildMessage = (content: TMessage['content']): TMessage =>
|
||||
|
|
@ -71,6 +72,29 @@ describe('applyActivityLabelPart', () => {
|
|||
pending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('never lets a stale pending placeholder overwrite an empty finalized phase', () => {
|
||||
const finalized = labelPart({ pending: false });
|
||||
Object.assign(finalized, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
activity_count: 2,
|
||||
});
|
||||
const message = buildMessage([finalized as never]);
|
||||
|
||||
const updated = applyActivityLabelPart(message, {
|
||||
index: 0,
|
||||
part: labelPart({ pending: true }),
|
||||
});
|
||||
|
||||
expect(updated).toBe(message);
|
||||
expect((updated.content as unknown[])[0]).toMatchObject({
|
||||
activity_label: '',
|
||||
activity_label_type: 'phase',
|
||||
pending: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastVisibleContentIdx', () => {
|
||||
|
|
@ -105,6 +129,61 @@ describe('lastVisibleContentIdx', () => {
|
|||
sparse[1] = tool;
|
||||
expect(lastVisibleContentIdx(sparse)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps sparse late-label adoption bounded to defined slots', () => {
|
||||
const sparse = new Array<TMessageContentParts | undefined>(10_000);
|
||||
sparse[0] = tool;
|
||||
sparse[9_998] = labelPart({
|
||||
activity_label: 'Recorded the delayed result',
|
||||
pending: false,
|
||||
}) as never;
|
||||
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
sparse[9_999] = phase as never;
|
||||
|
||||
expect(lastVisibleContentIdx(sparse)).toBe(9_999);
|
||||
});
|
||||
|
||||
it('keeps an appended phase marker as the visible tail when its final slot is empty', () => {
|
||||
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
|
||||
|
||||
expect(lastVisibleContentIdx([tool, emptyText, phase as never])).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the phase marker visible when an adopted late label follows an empty final slot', () => {
|
||||
const child = labelPart({ activity_label: 'Recorded the delayed result', pending: false });
|
||||
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
|
||||
|
||||
expect(lastVisibleContentIdx([tool, emptyText, child as never, phase as never])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offsetActivityPhaseBoundary', () => {
|
||||
it('folds only boundaries covered by the merged first completion part', () => {
|
||||
expect(offsetActivityPhaseBoundary(0, 5, true)).toBe(4);
|
||||
expect(offsetActivityPhaseBoundary(1, 5, true)).toBe(5);
|
||||
expect(offsetActivityPhaseBoundary(3, 5, true)).toBe(8);
|
||||
expect(offsetActivityPhaseBoundary(3, 5, false)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupActivityPhases', () => {
|
||||
|
|
@ -129,6 +208,133 @@ describe('groupActivityPhases', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('renders an appended parent marker before the final text using its explicit end', () => {
|
||||
const phase = labelPart({ activity_label: 'Completed the full investigation', pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 2,
|
||||
activity_count: 2,
|
||||
});
|
||||
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
|
||||
const segments = groupActivityPhases([tool, tool, final, phase as never]);
|
||||
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments?.[0]).toMatchObject({
|
||||
type: 'phase',
|
||||
labelIndex: 3,
|
||||
startIndex: 0,
|
||||
content: [tool, tool],
|
||||
});
|
||||
expect(segments?.[1]).toMatchObject({
|
||||
type: 'content',
|
||||
startIndex: 2,
|
||||
content: [final],
|
||||
});
|
||||
expect(lastVisibleContentIdx([tool, tool, final, phase as never])).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a late child label in the phase while leaving final text outside', () => {
|
||||
const child = labelPart({
|
||||
activity_label: 'Recorded the delayed child result',
|
||||
pending: false,
|
||||
tool_call_ids: ['t1'],
|
||||
});
|
||||
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
|
||||
const content = [tool, final, child as never, phase as never];
|
||||
|
||||
const segments = groupActivityPhases(content);
|
||||
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments?.[0]).toMatchObject({
|
||||
type: 'phase',
|
||||
startIndex: 0,
|
||||
content: [tool, child],
|
||||
contentIndices: [0, 2],
|
||||
});
|
||||
expect(segments?.[1]).toMatchObject({
|
||||
type: 'content',
|
||||
startIndex: 1,
|
||||
content: [final],
|
||||
contentIndices: [1],
|
||||
});
|
||||
expect(lastVisibleContentIdx(content)).toBe(1);
|
||||
});
|
||||
|
||||
it('groups a sparse late child label without walking the empty range', () => {
|
||||
const content = new Array<TMessageContentParts | undefined>(10_000);
|
||||
const child = labelPart({ activity_label: 'Recorded the delayed result', pending: false });
|
||||
const phase = labelPart({
|
||||
activity_label: 'Completed the sparse investigation',
|
||||
pending: false,
|
||||
});
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
content[0] = tool;
|
||||
content[9_998] = child as never;
|
||||
content[9_999] = phase as never;
|
||||
|
||||
const segments = groupActivityPhases(content);
|
||||
|
||||
expect(segments?.[0]).toMatchObject({
|
||||
type: 'phase',
|
||||
startIndex: 0,
|
||||
labelIndex: 9_999,
|
||||
});
|
||||
if (segments?.[0]?.type === 'phase') {
|
||||
expect(segments[0].content[0]).toBe(tool);
|
||||
expect(segments[0].content[1]).toBe(child);
|
||||
expect(segments[0].contentIndices).toEqual([0, 9_998]);
|
||||
expect(segments[0].content).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores a late child label when the parent label resolves empty', () => {
|
||||
const child = labelPart({
|
||||
activity_label: 'Recorded the delayed child result',
|
||||
pending: false,
|
||||
tool_call_ids: ['t1'],
|
||||
});
|
||||
const phase = labelPart({ pending: false });
|
||||
Object.assign(phase, {
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
});
|
||||
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
|
||||
const content = [tool, final, child as never, phase as never];
|
||||
|
||||
const segments = groupActivityPhases(content);
|
||||
|
||||
expect(segments).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'content',
|
||||
startIndex: 0,
|
||||
content: [tool, child],
|
||||
contentIndices: [0, 2],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'content',
|
||||
startIndex: 1,
|
||||
content: [final],
|
||||
contentIndices: [1],
|
||||
}),
|
||||
]);
|
||||
expect(lastVisibleContentIdx(content)).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves pending or empty parent markers on the feature-off path', () => {
|
||||
const pending = labelPart();
|
||||
Object.assign(pending, { activity_label_type: 'phase', activity_start_index: 0 });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { TMessage, TActivityLabelEvent, TMessageContentParts } from 'librec
|
|||
type ActivityLabelPart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
|
||||
activity_label_type?: 'phase';
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
activity_count?: number;
|
||||
agent_ids?: string[];
|
||||
};
|
||||
|
|
@ -12,11 +13,13 @@ export type ActivityPhaseSegment =
|
|||
| {
|
||||
type: 'content';
|
||||
content: Array<TMessageContentParts | undefined>;
|
||||
contentIndices: number[];
|
||||
startIndex: number;
|
||||
}
|
||||
| {
|
||||
type: 'phase';
|
||||
content: Array<TMessageContentParts | undefined>;
|
||||
contentIndices: number[];
|
||||
startIndex: number;
|
||||
labelPart: ActivityLabelPart;
|
||||
labelIndex: number;
|
||||
|
|
@ -33,6 +36,66 @@ function isVisibleContentPart(part: TMessageContentParts | undefined): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isLogicallyEarlierPhaseMarker(
|
||||
parts: ReadonlyArray<TMessageContentParts | undefined>,
|
||||
index: number,
|
||||
): boolean {
|
||||
const part = parts[index];
|
||||
const label = getActivityLabelPart(part);
|
||||
if (!isPhaseActivityLabel(label) || typeof label?.activity_end_index !== 'number') {
|
||||
return false;
|
||||
}
|
||||
const endIndex = Math.max(0, Math.min(index, label.activity_end_index));
|
||||
if (endIndex >= index) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(parts).some((key) => {
|
||||
const trailingIndex = Number(key);
|
||||
if (trailingIndex < endIndex || trailingIndex >= index) {
|
||||
return false;
|
||||
}
|
||||
const trailingPart = parts[trailingIndex];
|
||||
if (!isVisibleContentPart(trailingPart)) {
|
||||
return false;
|
||||
}
|
||||
if (getBatchActivityLabelPart(trailingPart) != null) {
|
||||
return false;
|
||||
}
|
||||
if (trailingPart?.type !== ContentTypes.TEXT) {
|
||||
return true;
|
||||
}
|
||||
const text =
|
||||
typeof trailingPart.text === 'string' ? trailingPart.text : trailingPart.text?.value;
|
||||
return typeof text === 'string' && text.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function findLateActivityLabelsConsumedByPhase(
|
||||
parts: ReadonlyArray<TMessageContentParts | undefined>,
|
||||
): Set<number> {
|
||||
const consumed = new Set<number>();
|
||||
let earliestPhaseEnd: number | undefined;
|
||||
const definedIndices = Object.keys(parts);
|
||||
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
|
||||
const index = Number(definedIndices[position]);
|
||||
const marker = getActivityLabelPart(parts[index]);
|
||||
if (
|
||||
isPhaseActivityLabel(marker) &&
|
||||
marker?.pending !== true &&
|
||||
typeof marker?.activity_end_index === 'number'
|
||||
) {
|
||||
earliestPhaseEnd = Math.min(earliestPhaseEnd ?? index, marker.activity_end_index);
|
||||
} else if (
|
||||
earliestPhaseEnd != null &&
|
||||
earliestPhaseEnd <= index &&
|
||||
getBatchActivityLabelPart(parts[index]) != null
|
||||
) {
|
||||
consumed.add(index);
|
||||
}
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
export function isPhaseActivityLabel(part: ActivityLabelPart | undefined): boolean {
|
||||
return part?.activity_label_type === 'phase';
|
||||
}
|
||||
|
|
@ -67,10 +130,19 @@ export function getActivityLabelText(part: ActivityLabelPart | undefined): strin
|
|||
return typeof label === 'string' ? label.trim() : '';
|
||||
}
|
||||
|
||||
/** Maps a completion-local half-open boundary into edited-response coordinates. */
|
||||
export function offsetActivityPhaseBoundary(
|
||||
boundary: number,
|
||||
prefixLength: number,
|
||||
foldedFirstPart: boolean,
|
||||
): number {
|
||||
return boundary + prefixLength - (foldedFirstPart && boundary <= 1 ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions completed phase markers into collapsed parent groups while
|
||||
* carrying absolute start offsets alongside dense content slices. Empty/pending
|
||||
* markers deliberately return no phase segment, preserving feature-off UI.
|
||||
* carrying absolute indexes alongside compact content slices. Pending markers
|
||||
* preserve feature-off UI; finalized empty markers only restore child order.
|
||||
*/
|
||||
export function groupActivityPhases(
|
||||
content: Array<TMessageContentParts | undefined> | undefined,
|
||||
|
|
@ -78,13 +150,13 @@ export function groupActivityPhases(
|
|||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
const completed = content
|
||||
.map((part, index) => ({ part: getActivityLabelPart(part), index }))
|
||||
const definedIndices = Object.keys(content).map(Number);
|
||||
const completed = definedIndices
|
||||
.map((index) => ({ part: getActivityLabelPart(content[index]), index }))
|
||||
.filter(
|
||||
({ part }) =>
|
||||
isPhaseActivityLabel(part) &&
|
||||
part?.pending !== true &&
|
||||
getActivityLabelText(part).length > 0 &&
|
||||
typeof part?.activity_start_index === 'number',
|
||||
);
|
||||
if (completed.length === 0) {
|
||||
|
|
@ -93,47 +165,102 @@ export function groupActivityPhases(
|
|||
|
||||
const segments: ActivityPhaseSegment[] = [];
|
||||
let cursor = 0;
|
||||
/** Dense, disjoint slices copy every part at most once. `startIndex` carries
|
||||
* the absolute transcript position into the recursive renderer. */
|
||||
const slice = (start: number, end: number) => {
|
||||
const segmentContent = content.slice(start, end);
|
||||
return {
|
||||
content: segmentContent,
|
||||
startIndex: start,
|
||||
hasContent: segmentContent.some(isVisibleContentPart),
|
||||
};
|
||||
let definedPosition = 0;
|
||||
const collect = () => ({
|
||||
content: [] as Array<TMessageContentParts | undefined>,
|
||||
contentIndices: [] as number[],
|
||||
hasContent: false,
|
||||
});
|
||||
const append = (segment: ReturnType<typeof collect>, partIndex: number) => {
|
||||
const child = content[partIndex];
|
||||
segment.content.push(child);
|
||||
segment.contentIndices.push(partIndex);
|
||||
segment.hasContent ||= isVisibleContentPart(child);
|
||||
};
|
||||
/** Phase markers and defined content indexes are both sorted. Walk them in
|
||||
* lockstep so every ordinary part is classified once, even when a custom
|
||||
* 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 end = Math.max(start, Math.min(index, Math.max(0, part.activity_end_index ?? index)));
|
||||
const adjacent = collect();
|
||||
const phase = collect();
|
||||
const trailing = collect();
|
||||
while (definedPosition < definedIndices.length && definedIndices[definedPosition] < index) {
|
||||
const childIndex = definedIndices[definedPosition];
|
||||
definedPosition += 1;
|
||||
if (childIndex < cursor) {
|
||||
continue;
|
||||
}
|
||||
if (childIndex < start) {
|
||||
append(adjacent, childIndex);
|
||||
} else if (childIndex < end || getBatchActivityLabelPart(content[childIndex]) != null) {
|
||||
append(phase, childIndex);
|
||||
} else {
|
||||
append(trailing, childIndex);
|
||||
}
|
||||
}
|
||||
if (definedIndices[definedPosition] === index) {
|
||||
definedPosition += 1;
|
||||
}
|
||||
if (start > cursor) {
|
||||
const adjacent = slice(cursor, start);
|
||||
segments.push({
|
||||
type: 'content',
|
||||
content: adjacent.content,
|
||||
startIndex: adjacent.startIndex,
|
||||
contentIndices: adjacent.contentIndices,
|
||||
startIndex: cursor,
|
||||
});
|
||||
}
|
||||
const labelText = getActivityLabelText(part);
|
||||
if (labelText) {
|
||||
segments.push({
|
||||
type: 'phase',
|
||||
content: phase.content,
|
||||
contentIndices: phase.contentIndices,
|
||||
startIndex: start,
|
||||
labelPart: part,
|
||||
labelIndex: index,
|
||||
hasContent: phase.hasContent,
|
||||
});
|
||||
} else {
|
||||
/** A failed/empty parent stays visually feature-off, but its bounds are
|
||||
* still authoritative: delayed child labels must move back beside the
|
||||
* tools they describe instead of rendering after the final answer. */
|
||||
segments.push({
|
||||
type: 'content',
|
||||
content: phase.content,
|
||||
contentIndices: phase.contentIndices,
|
||||
startIndex: start,
|
||||
});
|
||||
}
|
||||
if (end < index) {
|
||||
segments.push({
|
||||
type: 'content',
|
||||
content: trailing.content,
|
||||
contentIndices: trailing.contentIndices,
|
||||
startIndex: end,
|
||||
});
|
||||
}
|
||||
const phase = slice(start, index);
|
||||
segments.push({
|
||||
type: 'phase',
|
||||
content: phase.content,
|
||||
startIndex: phase.startIndex,
|
||||
labelPart: part,
|
||||
labelIndex: index,
|
||||
hasContent: phase.hasContent,
|
||||
});
|
||||
cursor = index + 1;
|
||||
}
|
||||
if (cursor < content.length) {
|
||||
const adjacent = slice(cursor, content.length);
|
||||
const adjacent = collect();
|
||||
while (definedPosition < definedIndices.length) {
|
||||
const childIndex = definedIndices[definedPosition];
|
||||
definedPosition += 1;
|
||||
if (childIndex >= cursor) {
|
||||
append(adjacent, childIndex);
|
||||
}
|
||||
}
|
||||
segments.push({
|
||||
type: 'content',
|
||||
content: adjacent.content,
|
||||
startIndex: adjacent.startIndex,
|
||||
contentIndices: adjacent.contentIndices,
|
||||
startIndex: cursor,
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
|
|
@ -151,9 +278,14 @@ export function lastVisibleContentIdx(
|
|||
content: ReadonlyArray<TMessageContentParts | undefined> | undefined,
|
||||
): number {
|
||||
const parts = content ?? [];
|
||||
const consumedLateActivityLabels = findLateActivityLabelsConsumedByPhase(parts);
|
||||
let last = parts.length - 1;
|
||||
while (last >= 0 && last in parts) {
|
||||
if (isVisibleContentPart(parts[last])) {
|
||||
if (
|
||||
isVisibleContentPart(parts[last]) &&
|
||||
!isLogicallyEarlierPhaseMarker(parts, last) &&
|
||||
!consumedLateActivityLabels.has(last)
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
last -= 1;
|
||||
|
|
@ -166,7 +298,12 @@ export function lastVisibleContentIdx(
|
|||
const definedIndices = Object.keys(parts);
|
||||
for (let i = definedIndices.length - 1; i >= 0; i -= 1) {
|
||||
const index = Number(definedIndices[i]);
|
||||
if (index <= last && isVisibleContentPart(parts[index])) {
|
||||
if (
|
||||
index <= last &&
|
||||
isVisibleContentPart(parts[index]) &&
|
||||
!isLogicallyEarlierPhaseMarker(parts, index) &&
|
||||
!consumedLateActivityLabels.has(index)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
|
@ -221,18 +358,12 @@ export function applyActivityLabelPart(message: TMessage, event: TActivityLabelE
|
|||
existing.pending === part.pending &&
|
||||
existing.activity_label_type === incoming.activity_label_type &&
|
||||
existing.activity_start_index === incoming.activity_start_index &&
|
||||
existing.activity_end_index === incoming.activity_end_index &&
|
||||
existing.activity_count === incoming.activity_count
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
const existingText = existing?.[ContentTypes.ACTIVITY_LABEL];
|
||||
if (
|
||||
existing != null &&
|
||||
existing.pending !== true &&
|
||||
typeof existingText === 'string' &&
|
||||
existingText.length > 0 &&
|
||||
part.pending === true
|
||||
) {
|
||||
if (existing != null && existing.pending !== true && part.pending === true) {
|
||||
return message;
|
||||
}
|
||||
const nextContent = [...content] as TMessageContentParts[];
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type PersistedContentPart = {
|
|||
activity_label?: string;
|
||||
activity_label_type?: string;
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
activity_count?: number;
|
||||
pending?: boolean;
|
||||
tool_call?: { id?: string };
|
||||
|
|
@ -148,6 +149,38 @@ test.describe('parent activity phases', () => {
|
|||
|
||||
const parent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);
|
||||
await expect(parent).toBeVisible({ timeout: 60000 });
|
||||
/** Inspect the durable projection before the live DOM assertion so a
|
||||
* failure identifies whether the server bound or client grouping is
|
||||
* wrong. This remains a useful contract assertion after the bug is fixed. */
|
||||
const liveConversationId = await getConversationId(page);
|
||||
const liveToken = await getAccessToken(page);
|
||||
let liveAssistant: PersistedMessage | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await fetchJson<PersistedMessage[]>(
|
||||
page,
|
||||
`/api/messages/${encodeURIComponent(liveConversationId)}`,
|
||||
liveToken,
|
||||
);
|
||||
liveAssistant = messages.find(
|
||||
(message) =>
|
||||
message.isCreatedByUser === false && messageText(message).includes(finalText),
|
||||
);
|
||||
return liveAssistant?.unfinished;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(false);
|
||||
const liveContent = liveAssistant?.content ?? [];
|
||||
const livePhase = liveContent.find(
|
||||
(part) => part?.type === 'activity_label' && part.activity_label_type === 'phase',
|
||||
);
|
||||
const liveFinalTextIndex = liveContent.findIndex((part) =>
|
||||
contentPartText(part).includes(finalText),
|
||||
);
|
||||
expect(liveFinalTextIndex).toBe(livePhase?.activity_end_index);
|
||||
|
||||
await expect(messagesView(page).getByText(finalText)).toBeVisible({ timeout: 60000 });
|
||||
await parent.click();
|
||||
await expect(messagesView(page).getByRole('button', { name: childLabels.first })).toBeVisible();
|
||||
|
|
@ -210,8 +243,12 @@ test.describe('parent activity phases', () => {
|
|||
pending: false,
|
||||
});
|
||||
expect(phasePart?.activity_start_index).toBeGreaterThanOrEqual(0);
|
||||
expect(phasePart?.activity_start_index).toBeLessThan(phaseIndex);
|
||||
const phaseChildren = content.slice(phasePart?.activity_start_index ?? phaseIndex, phaseIndex);
|
||||
expect(phasePart?.activity_end_index).toBeGreaterThan(phasePart?.activity_start_index ?? -1);
|
||||
expect(phasePart?.activity_end_index).toBeLessThanOrEqual(phaseIndex);
|
||||
const phaseChildren = content.slice(
|
||||
phasePart?.activity_start_index ?? phaseIndex,
|
||||
phasePart?.activity_end_index ?? phaseIndex,
|
||||
);
|
||||
expect(phaseChildren.map((part) => part?.tool_call?.id).filter(Boolean)).toEqual(
|
||||
expect.arrayContaining([firstToolCallId, secondToolCallId]),
|
||||
);
|
||||
|
|
@ -219,7 +256,7 @@ test.describe('parent activity phases', () => {
|
|||
expect.arrayContaining([childLabels.first, childLabels.second]),
|
||||
);
|
||||
const finalTextIndex = content.findIndex((part) => contentPartText(part).includes(finalText));
|
||||
expect(finalTextIndex).toBeGreaterThan(phaseIndex);
|
||||
expect(finalTextIndex).toBe(phasePart?.activity_end_index);
|
||||
|
||||
await page.reload();
|
||||
const reloadedParent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);
|
||||
|
|
|
|||
|
|
@ -187,11 +187,12 @@ describe('synthesizeActivityLabelGapEvents', () => {
|
|||
activity_label: 'Inspected and fixed the session',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: 0,
|
||||
activity_end_index: 1,
|
||||
activity_count: 2,
|
||||
pending: false,
|
||||
},
|
||||
];
|
||||
const fresh: LooseContentPart[] = [{ ...snapshot[0], activity_start_index: 1 }];
|
||||
const fresh: LooseContentPart[] = [{ ...snapshot[0], activity_end_index: 2 }];
|
||||
|
||||
expect(synthesizeActivityLabelGapEvents(snapshot, fresh, meta)).toHaveLength(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export function synthesizeActivityLabelGapEvents(
|
|||
snapshot[ContentTypes.ACTIVITY_LABEL] === part[ContentTypes.ACTIVITY_LABEL] &&
|
||||
snapshot.activity_label_type === part.activity_label_type &&
|
||||
snapshot.activity_start_index === part.activity_start_index &&
|
||||
snapshot.activity_end_index === part.activity_end_index &&
|
||||
snapshot.activity_count === part.activity_count &&
|
||||
snapshot.pending === part.pending;
|
||||
if (isSameLabel) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import { stringifyActivityEvidence } from '~/agents/activityLabels/runtime';
|
|||
|
||||
type PostToolBatchInput = HookInputByEvent['PostToolBatch'];
|
||||
type BatchEntry = PostToolBatchInput['entries'][number];
|
||||
type AssistantContextEntry = { stepId?: string; text: string };
|
||||
|
||||
export type AssistantTextPhase = 'commentary' | 'final_answer';
|
||||
|
||||
|
|
@ -28,6 +29,8 @@ type TrackedActivity = ActivityPhaseEntry & {
|
|||
childLabelIndex?: number;
|
||||
/** Stable anchors survive content filtering and prepends across HITL resume. */
|
||||
toolCallIds?: string[];
|
||||
/** Original boundary retained while only part of a saved tool batch is materialized. */
|
||||
unresolvedToolStartIndex?: number;
|
||||
};
|
||||
|
||||
export interface ActivityPhaseSnapshot {
|
||||
|
|
@ -38,6 +41,14 @@ export interface ActivityPhaseSnapshot {
|
|||
partialActivityCount: number;
|
||||
agentIds: string[];
|
||||
activities: TrackedActivity[];
|
||||
overflowActivityStartIndex?: number;
|
||||
overflowToolCallIds?: string[];
|
||||
/** IDs tied to the saved numeric overflow boundary, including equal-index batches. */
|
||||
overflowBoundaryToolCallIds?: string[];
|
||||
/** @deprecated Stable anchor retained for snapshots created before multi-anchor support. */
|
||||
overflowReasoningExcerpt?: string;
|
||||
/** Bounded stable anchors for reasoning-only overflow after HITL content compaction. */
|
||||
overflowReasoningAnchors?: string[];
|
||||
assistantContext: string[];
|
||||
pendingReasoning: Array<{
|
||||
key: string;
|
||||
|
|
@ -87,6 +98,8 @@ export interface ActivityPhaseWiring {
|
|||
) => Record<string, EventHandler> | undefined;
|
||||
/** A steer is a hard semantic boundary; incomplete evidence is discarded. */
|
||||
drop: () => void;
|
||||
/** Finalizes unphased evidence once the root AgentRun has actually completed. */
|
||||
complete: () => void;
|
||||
/** Bounded state needed to continue the same phase after a HITL pause. */
|
||||
snapshot: () => ActivityPhaseSnapshot;
|
||||
}
|
||||
|
|
@ -96,6 +109,7 @@ const DEFAULT_CHAR_LIMIT = 600;
|
|||
const MIN_ACTIVITIES = 2;
|
||||
const MAX_CONTEXT_ITEMS = 6;
|
||||
const MAX_EXCERPT_CHARS = 600;
|
||||
const REASONING_ANCHOR_CHARS = 80;
|
||||
const OUTPUT_CHAR_LIMIT = 160;
|
||||
const PHASE_TIMEOUT_MS = 12_000;
|
||||
/** Twelve enter the SDK prompt; one extra preserves its omitted-activity row. */
|
||||
|
|
@ -160,13 +174,19 @@ function buildSignal(signal?: AbortSignal): AbortSignal {
|
|||
: timeout;
|
||||
}
|
||||
|
||||
function definedPartIndices(parts: ReadonlyArray<LooseContentPart | null | undefined>): number[] {
|
||||
return Object.keys(parts).map(Number);
|
||||
}
|
||||
|
||||
function findLastPartIndex(
|
||||
parts: ReadonlyArray<LooseContentPart | null | undefined>,
|
||||
type: string,
|
||||
): number {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i]?.type === type) {
|
||||
return i;
|
||||
const indices = definedPartIndices(parts);
|
||||
for (let position = indices.length - 1; position >= 0; position -= 1) {
|
||||
const index = indices[position];
|
||||
if (parts[index]?.type === type) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return Math.max(0, parts.length - 1);
|
||||
|
|
@ -177,14 +197,14 @@ function findBatchStart(
|
|||
toolCallIds: Set<string>,
|
||||
): number {
|
||||
let first = -1;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
for (const index of definedPartIndices(parts)) {
|
||||
const part = parts[index];
|
||||
if (
|
||||
part?.type === ContentTypes.TOOL_CALL &&
|
||||
typeof part.tool_call?.id === 'string' &&
|
||||
toolCallIds.has(part.tool_call.id)
|
||||
) {
|
||||
first = first < 0 ? i : Math.min(first, i);
|
||||
first = first < 0 ? index : Math.min(first, index);
|
||||
}
|
||||
}
|
||||
return first >= 0 ? first : Math.max(0, parts.length - 1);
|
||||
|
|
@ -200,16 +220,114 @@ function findTrackedStart(
|
|||
}
|
||||
const excerpt = activity.thinkingExcerpts?.[0]?.trim();
|
||||
if (excerpt) {
|
||||
const needle = excerpt.slice(0, 80);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i]?.type === ContentTypes.THINK && textValue(parts[i]?.think).includes(needle)) {
|
||||
return i;
|
||||
}
|
||||
const reasoningStart = findReasoningExcerptStart(parts, excerpt);
|
||||
if (reasoningStart != null) {
|
||||
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(
|
||||
anchors: Set<string>,
|
||||
index: ReasoningAnchorIndex,
|
||||
excerpt: string,
|
||||
): void {
|
||||
const anchor = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS);
|
||||
if (!anchor || anchors.has(anchor)) {
|
||||
return;
|
||||
}
|
||||
anchors.add(anchor);
|
||||
const matchingLength = index.get(anchor.length);
|
||||
if (matchingLength != null) {
|
||||
matchingLength.add(anchor);
|
||||
} else {
|
||||
index.set(anchor.length, new Set([anchor]));
|
||||
}
|
||||
}
|
||||
|
||||
function includesReasoningAnchor(text: string, index: ReasoningAnchorIndex): boolean {
|
||||
for (const [length, anchors] of index) {
|
||||
for (let offset = 0; offset <= text.length - length; offset += 1) {
|
||||
if (anchors.has(text.slice(offset, offset + length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
@ -237,7 +355,7 @@ function findReasoningStart(
|
|||
if (startIndex != null && matches(parts[startIndex])) {
|
||||
return startIndex;
|
||||
}
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
for (const index of definedPartIndices(parts)) {
|
||||
if (matches(parts[index])) {
|
||||
return index;
|
||||
}
|
||||
|
|
@ -301,9 +419,9 @@ export function createAssistantPhaseStampingHandlers(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collects run-wide logical activities and emits one parent summary at a text
|
||||
* boundary. The summary call is detached; the boundary only pays the cheap
|
||||
* synchronous slot claim needed to keep streamed content indices stable.
|
||||
* Collects run-wide logical activities and emits one parent summary at an
|
||||
* explicit final-answer boundary or root-run completion. The summary call is
|
||||
* detached; final-answer streams only pay the synchronous slot reservation.
|
||||
*/
|
||||
export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): ActivityPhaseWiring {
|
||||
const maxPerRun = deps.maxPerRun ?? DEFAULT_MAX_PER_RUN;
|
||||
|
|
@ -312,15 +430,35 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
const initialSnapshot = deps.initialSnapshot?.version === 1 ? deps.initialSnapshot : undefined;
|
||||
let generated = Math.max(
|
||||
initialSnapshot?.generated ?? 0,
|
||||
content.filter(
|
||||
(part) => part?.type === ContentTypes.ACTIVITY_LABEL && part.activity_label_type === 'phase',
|
||||
).length,
|
||||
definedPartIndices(content).filter((index) => {
|
||||
const part = content[index];
|
||||
return part?.type === ContentTypes.ACTIVITY_LABEL && part.activity_label_type === 'phase';
|
||||
}).length,
|
||||
);
|
||||
const initiallyMaterializedToolIds = new Set(
|
||||
definedPartIndices(content).flatMap((index) => {
|
||||
const part = content[index];
|
||||
const id = part?.type === ContentTypes.TOOL_CALL ? part.tool_call?.id : undefined;
|
||||
return typeof id === 'string' ? [id] : [];
|
||||
}),
|
||||
);
|
||||
let activities: TrackedActivity[] =
|
||||
initialSnapshot?.activities.map((activity) => ({
|
||||
...activity,
|
||||
startIndex: findTrackedStart(content, activity),
|
||||
})) ?? [];
|
||||
initialSnapshot?.activities.map((activity) => {
|
||||
const { unresolvedToolStartIndex, ...retainedActivity } = activity;
|
||||
const startIndex = findTrackedStart(content, activity);
|
||||
const toolCallIds = activity.toolCallIds ?? [];
|
||||
const hasUnresolvedTool = toolCallIds.some((id) => !initiallyMaterializedToolIds.has(id));
|
||||
return {
|
||||
...retainedActivity,
|
||||
startIndex,
|
||||
...(hasUnresolvedTool && {
|
||||
unresolvedToolStartIndex: Math.max(
|
||||
unresolvedToolStartIndex ?? activity.startIndex,
|
||||
startIndex,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}) ?? [];
|
||||
let activityCount = initialSnapshot?.activityCount ?? activities.length;
|
||||
let failedActivityCount =
|
||||
initialSnapshot?.failedActivityCount ??
|
||||
|
|
@ -328,23 +466,88 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
let partialActivityCount =
|
||||
initialSnapshot?.partialActivityCount ??
|
||||
activities.filter((activity) => activity.status === 'partial').length;
|
||||
const overflowToolCallIds = new Set(initialSnapshot?.overflowToolCallIds ?? []);
|
||||
const overflowBoundaryToolCallIds = new Set(
|
||||
initialSnapshot?.overflowBoundaryToolCallIds ?? initialSnapshot?.overflowToolCallIds ?? [],
|
||||
);
|
||||
const materializedOverflowToolIds = new Set<string>();
|
||||
const rebasedOverflowToolIndexes = definedPartIndices(content).filter((index) => {
|
||||
const part = content[index];
|
||||
const toolCallId = part?.type === ContentTypes.TOOL_CALL ? part.tool_call?.id : undefined;
|
||||
const matches = typeof toolCallId === 'string' && overflowToolCallIds.has(toolCallId);
|
||||
if (matches) {
|
||||
materializedOverflowToolIds.add(toolCallId);
|
||||
}
|
||||
return matches;
|
||||
});
|
||||
const overflowReasoningAnchors = new Set<string>();
|
||||
const overflowReasoningAnchorIndex: ReasoningAnchorIndex = new Map();
|
||||
const initialOverflowReasoningAnchors =
|
||||
initialSnapshot?.overflowReasoningAnchors ??
|
||||
(initialSnapshot?.overflowReasoningExcerpt != null
|
||||
? [initialSnapshot.overflowReasoningExcerpt]
|
||||
: []);
|
||||
for (const anchor of initialOverflowReasoningAnchors) {
|
||||
addReasoningAnchor(overflowReasoningAnchors, overflowReasoningAnchorIndex, anchor);
|
||||
}
|
||||
let rebasedOverflowReasoningIndex: number | undefined;
|
||||
if (overflowReasoningAnchors.size > 0) {
|
||||
for (const index of definedPartIndices(content)) {
|
||||
const part = content[index];
|
||||
if (
|
||||
part?.type === ContentTypes.THINK &&
|
||||
includesReasoningAnchor(textValue(part.think), overflowReasoningAnchorIndex)
|
||||
) {
|
||||
rebasedOverflowReasoningIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rebasedOverflowStartIndex =
|
||||
rebasedOverflowToolIndexes.length > 0
|
||||
? Math.max(...rebasedOverflowToolIndexes, rebasedOverflowReasoningIndex ?? -1)
|
||||
: rebasedOverflowReasoningIndex;
|
||||
const hasUnresolvedBoundaryTool = [...overflowBoundaryToolCallIds].some(
|
||||
(id) => !materializedOverflowToolIds.has(id),
|
||||
);
|
||||
let overflowActivityStartIndex = hasUnresolvedBoundaryTool
|
||||
? Math.max(rebasedOverflowStartIndex ?? -1, initialSnapshot?.overflowActivityStartIndex ?? -1)
|
||||
: (rebasedOverflowStartIndex ??
|
||||
(overflowReasoningAnchors.size === 0
|
||||
? initialSnapshot?.overflowActivityStartIndex
|
||||
: undefined));
|
||||
if (overflowActivityStartIndex != null && overflowActivityStartIndex < 0) {
|
||||
overflowActivityStartIndex = undefined;
|
||||
}
|
||||
const contributingAgentIds = new Set(initialSnapshot?.agentIds ?? []);
|
||||
let assistantContext = (initialSnapshot?.assistantContext ?? []).slice(-MAX_CONTEXT_ITEMS);
|
||||
let assistantContext: AssistantContextEntry[] = (initialSnapshot?.assistantContext ?? [])
|
||||
.slice(-MAX_CONTEXT_ITEMS)
|
||||
.map((text) => ({ text }));
|
||||
const pendingReasoning = new Map<string, { text: string; agentId?: string; startIndex?: number }>(
|
||||
(initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => [
|
||||
key,
|
||||
{
|
||||
text: text.slice(-MAX_EXCERPT_CHARS),
|
||||
...(agentId != null && { agentId }),
|
||||
...(startIndex != null && { startIndex }),
|
||||
},
|
||||
]),
|
||||
(initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => {
|
||||
const boundedText = text.slice(-MAX_EXCERPT_CHARS);
|
||||
const needle = boundedText.trim().slice(0, REASONING_ANCHOR_CHARS);
|
||||
const rebasedStartIndex = needle ? findReasoningStart(content, boundedText, startIndex) : -1;
|
||||
const hasMaterializedReasoning =
|
||||
needle.length > 0 &&
|
||||
content[rebasedStartIndex]?.type === ContentTypes.THINK &&
|
||||
textValue(content[rebasedStartIndex]?.think).includes(needle);
|
||||
return [
|
||||
key,
|
||||
{
|
||||
text: boundedText,
|
||||
...(agentId != null && { agentId }),
|
||||
...(hasMaterializedReasoning && { startIndex: rebasedStartIndex }),
|
||||
},
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
const reasoningStepKeys = new Map<string, string>();
|
||||
const stepKinds = new Map<
|
||||
string,
|
||||
{ kind: 'text' | 'think'; phase?: AssistantTextPhase; captureContext?: boolean }
|
||||
>();
|
||||
const textContextByStepId = new Map<string, AssistantContextEntry>();
|
||||
let lastRootTextStepId: string | undefined;
|
||||
|
||||
const clear = () => {
|
||||
activities = [];
|
||||
|
|
@ -352,10 +555,17 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
pendingReasoning.clear();
|
||||
reasoningStepKeys.clear();
|
||||
stepKinds.clear();
|
||||
textContextByStepId.clear();
|
||||
activityCount = 0;
|
||||
failedActivityCount = 0;
|
||||
partialActivityCount = 0;
|
||||
overflowActivityStartIndex = undefined;
|
||||
overflowToolCallIds.clear();
|
||||
overflowBoundaryToolCallIds.clear();
|
||||
overflowReasoningAnchors.clear();
|
||||
overflowReasoningAnchorIndex.clear();
|
||||
contributingAgentIds.clear();
|
||||
lastRootTextStepId = undefined;
|
||||
};
|
||||
|
||||
const trackActivity = (activity: TrackedActivity) => {
|
||||
|
|
@ -370,6 +580,26 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
}
|
||||
if (activities.length < MAX_RETAINED_ACTIVITIES) {
|
||||
activities.push(activity);
|
||||
} else {
|
||||
if (overflowActivityStartIndex == null || activity.startIndex > overflowActivityStartIndex) {
|
||||
overflowActivityStartIndex = activity.startIndex;
|
||||
overflowBoundaryToolCallIds.clear();
|
||||
}
|
||||
const reasoningExcerpts = activity.thinkingExcerpts;
|
||||
const reasoningExcerpt = reasoningExcerpts?.[reasoningExcerpts.length - 1]?.trim();
|
||||
if (reasoningExcerpt) {
|
||||
addReasoningAnchor(
|
||||
overflowReasoningAnchors,
|
||||
overflowReasoningAnchorIndex,
|
||||
reasoningExcerpt,
|
||||
);
|
||||
}
|
||||
for (const id of activity.toolCallIds ?? []) {
|
||||
overflowToolCallIds.add(id);
|
||||
if (activity.startIndex === overflowActivityStartIndex) {
|
||||
overflowBoundaryToolCallIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -380,6 +610,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
failedActivityCount,
|
||||
partialActivityCount,
|
||||
agentIds: [...contributingAgentIds],
|
||||
...(overflowActivityStartIndex != null && { overflowActivityStartIndex }),
|
||||
...(overflowToolCallIds.size > 0 && { overflowToolCallIds: [...overflowToolCallIds] }),
|
||||
...(overflowActivityStartIndex != null && {
|
||||
overflowBoundaryToolCallIds: [...overflowBoundaryToolCallIds],
|
||||
}),
|
||||
...(overflowReasoningAnchors.size > 0 && {
|
||||
overflowReasoningAnchors: [...overflowReasoningAnchors],
|
||||
}),
|
||||
activities: activities.map((activity) => ({
|
||||
...activity,
|
||||
...(activity.entries != null && {
|
||||
|
|
@ -396,7 +634,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
thinkingExcerpts: activity.thinkingExcerpts.map((text) => text.slice(-MAX_EXCERPT_CHARS)),
|
||||
}),
|
||||
})),
|
||||
assistantContext: assistantContext.slice(-MAX_CONTEXT_ITEMS),
|
||||
assistantContext: assistantContext.slice(-MAX_CONTEXT_ITEMS).map(({ text }) => text),
|
||||
pendingReasoning: [...pendingReasoning].map(([key, reasoning]) => ({
|
||||
key,
|
||||
text: reasoning.text.slice(-MAX_EXCERPT_CHARS),
|
||||
|
|
@ -431,7 +669,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
const resolveActivities = (snapshot: TrackedActivity[]): ActivityPhaseEntry[] => {
|
||||
const parts = deps.getContentParts();
|
||||
return snapshot.map(
|
||||
({ childLabelIndex, toolCallIds, startIndex: _startIndex, ...activity }) => {
|
||||
({
|
||||
childLabelIndex,
|
||||
toolCallIds,
|
||||
startIndex: _startIndex,
|
||||
unresolvedToolStartIndex: _unresolvedToolStartIndex,
|
||||
...activity
|
||||
}) => {
|
||||
const matchesToolIds = (part: LooseContentPart | null | undefined): boolean => {
|
||||
if (part?.type !== ContentTypes.ACTIVITY_LABEL || part.activity_label_type === 'phase') {
|
||||
return false;
|
||||
|
|
@ -444,7 +688,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
};
|
||||
let child = childLabelIndex == null ? undefined : parts[childLabelIndex];
|
||||
if (!matchesToolIds(child) && toolCallIds != null && toolCallIds.length > 0) {
|
||||
child = parts.find(matchesToolIds);
|
||||
child = definedPartIndices(parts)
|
||||
.map((index) => parts[index])
|
||||
.find(matchesToolIds);
|
||||
}
|
||||
if (!matchesToolIds(child)) {
|
||||
return activity;
|
||||
|
|
@ -456,14 +702,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
);
|
||||
};
|
||||
|
||||
const close = (closingTextPhase?: AssistantTextPhase, hardBoundary = false) => {
|
||||
const close = (closingTextPhase?: AssistantTextPhase, requestedEndIndex?: number) => {
|
||||
addPendingReasoning();
|
||||
if (generated >= maxPerRun) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (activityCount < MIN_ACTIVITIES) {
|
||||
if (hardBoundary) clear();
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -480,7 +726,25 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
const toolStart = findTrackedToolStart(currentParts, activity);
|
||||
return toolStart != null ? { ...activity, startIndex: toolStart } : activity;
|
||||
});
|
||||
const contextSnapshot = [...assistantContext];
|
||||
const contextSnapshot = assistantContext.map(({ text }) => text);
|
||||
/** Completion-finalized phases leave the final root text outside their
|
||||
* UI bounds. Remove its matching retained excerpt from the label prompt
|
||||
* as well, or the parent can paraphrase the answer it does not contain.
|
||||
* Search from the tail because identical intermediate/final text should
|
||||
* discard only the most recent capture. */
|
||||
if (requestedEndIndex != null) {
|
||||
const excludedText = textValue(currentParts[requestedEndIndex]?.text)
|
||||
.trim()
|
||||
.slice(-MAX_EXCERPT_CHARS);
|
||||
if (excludedText) {
|
||||
for (let position = contextSnapshot.length - 1; position >= 0; position -= 1) {
|
||||
if (contextSnapshot[position].trim() === excludedText) {
|
||||
contextSnapshot.splice(position, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalActivityCount = activityCount;
|
||||
const failedCount = failedActivityCount;
|
||||
const partialCount = partialActivityCount;
|
||||
|
|
@ -488,8 +752,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
/** Pull leading commentary/reasoning into the parent card. A prior phase
|
||||
* marker or steer is the only hard UI boundary; plain text can be
|
||||
* intermediate context on providers that do not expose phase metadata. */
|
||||
for (let i = startIndex - 1; i >= 0; i--) {
|
||||
const prior = currentParts[i];
|
||||
const definedIndices = definedPartIndices(currentParts);
|
||||
let extendedStartIndex = 0;
|
||||
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
|
||||
const priorIndex = definedIndices[position];
|
||||
if (priorIndex >= startIndex) {
|
||||
continue;
|
||||
}
|
||||
const prior = currentParts[priorIndex];
|
||||
if (
|
||||
prior?.type === ContentTypes.STEER ||
|
||||
(prior?.type === ContentTypes.ACTIVITY_LABEL && prior.activity_label_type === 'phase') ||
|
||||
|
|
@ -497,10 +767,11 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
prior.phase === 'final_answer' &&
|
||||
textValue(prior.text).trim().length > 0)
|
||||
) {
|
||||
extendedStartIndex = priorIndex + 1;
|
||||
break;
|
||||
}
|
||||
startIndex = i;
|
||||
}
|
||||
startIndex = extendedStartIndex;
|
||||
const agentIds = [...contributingAgentIds];
|
||||
let phaseStatus: 'ok' | 'partial' | 'failed' = 'ok';
|
||||
if (failedCount === totalActivityCount) {
|
||||
|
|
@ -509,11 +780,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
phaseStatus = 'partial';
|
||||
}
|
||||
const index = deps.getContentParts().length;
|
||||
const endIndex = Math.max(startIndex, Math.min(index, requestedEndIndex ?? index));
|
||||
const part: LooseContentPart = {
|
||||
type: ContentTypes.ACTIVITY_LABEL,
|
||||
[ContentTypes.ACTIVITY_LABEL]: '',
|
||||
activity_label_type: 'phase',
|
||||
activity_start_index: startIndex,
|
||||
activity_end_index: endIndex,
|
||||
activity_count: totalActivityCount,
|
||||
...(agentIds.length > 0 && { agent_ids: agentIds }),
|
||||
status: phaseStatus,
|
||||
|
|
@ -583,15 +856,27 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
const ids = new Set(input.entries.map((entry) => entry.toolUseId));
|
||||
const parts = deps.getContentParts();
|
||||
let childLabelIndex: number | undefined;
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i];
|
||||
if (part?.type !== ContentTypes.ACTIVITY_LABEL || part.activity_label_type === 'phase') {
|
||||
continue;
|
||||
let batchStartIndex: number | undefined;
|
||||
const indices = definedPartIndices(parts);
|
||||
for (let position = indices.length - 1; position >= 0; position -= 1) {
|
||||
const index = indices[position];
|
||||
const part = parts[index];
|
||||
if (
|
||||
part?.type === ContentTypes.TOOL_CALL &&
|
||||
typeof part.tool_call?.id === 'string' &&
|
||||
ids.has(part.tool_call.id)
|
||||
) {
|
||||
batchStartIndex = index;
|
||||
}
|
||||
const childIds = Array.isArray(part.tool_call_ids) ? part.tool_call_ids : [];
|
||||
if (childIds.some((id) => typeof id === 'string' && ids.has(id))) {
|
||||
childLabelIndex = i;
|
||||
break;
|
||||
if (
|
||||
childLabelIndex == null &&
|
||||
part?.type === ContentTypes.ACTIVITY_LABEL &&
|
||||
part.activity_label_type !== 'phase'
|
||||
) {
|
||||
const childIds = Array.isArray(part.tool_call_ids) ? part.tool_call_ids : [];
|
||||
if (childIds.some((id) => typeof id === 'string' && ids.has(id))) {
|
||||
childLabelIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
const entries = input.entries.map((entry: BatchEntry) => ({
|
||||
|
|
@ -613,7 +898,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
...(reasoning ? { thinkingExcerpts: [reasoning.slice(0, MAX_EXCERPT_CHARS)] } : {}),
|
||||
...(input.executingAgentId != null && { agentId: input.executingAgentId }),
|
||||
status: activityStatus,
|
||||
startIndex: findBatchStart(parts, ids),
|
||||
startIndex: batchStartIndex ?? Math.max(0, parts.length - 1),
|
||||
toolCallIds: [...ids],
|
||||
...(childLabelIndex != null && { childLabelIndex }),
|
||||
});
|
||||
|
|
@ -664,26 +949,35 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
}
|
||||
return result;
|
||||
} else {
|
||||
if (step.groupId == null) {
|
||||
/** `final_answer` closes immediately before its text streams.
|
||||
* Commentary does not: if it is the root run's last text,
|
||||
* completion must leave it outside the parent like any
|
||||
* unphased answer. Later activities still invalidate this
|
||||
* candidate in `complete`. */
|
||||
lastRootTextStepId = phase === 'final_answer' ? undefined : step.id;
|
||||
}
|
||||
if (phase === 'final_answer' && step.groupId == null) {
|
||||
addPendingReasoning(step.agentId ?? 'root');
|
||||
stepKinds.set(step.id, { kind, phase, captureContext: false });
|
||||
close(phase, true);
|
||||
close(phase);
|
||||
} else {
|
||||
if (phase == null && step.groupId == null) {
|
||||
addPendingReasoning(step.agentId ?? 'root');
|
||||
}
|
||||
const closesPhase =
|
||||
phase == null && step.groupId == null && activityCount >= MIN_ACTIVITIES;
|
||||
stepKinds.set(step.id, {
|
||||
kind,
|
||||
...(phase != null && { phase }),
|
||||
captureContext: !closesPhase,
|
||||
captureContext: true,
|
||||
});
|
||||
if (closesPhase) {
|
||||
close(undefined, false);
|
||||
} else {
|
||||
assistantContext.push('');
|
||||
if (assistantContext.length > MAX_CONTEXT_ITEMS) assistantContext.shift();
|
||||
const contextEntry: AssistantContextEntry = { stepId: step.id, text: '' };
|
||||
assistantContext.push(contextEntry);
|
||||
textContextByStepId.set(step.id, contextEntry);
|
||||
if (assistantContext.length > MAX_CONTEXT_ITEMS) {
|
||||
const removed = assistantContext.shift();
|
||||
if (removed?.stepId != null) {
|
||||
textContextByStepId.delete(removed.stepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -701,14 +995,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
const tracked = id ? stepKinds.get(id) : undefined;
|
||||
if (tracked?.kind === 'text' && tracked.captureContext === true) {
|
||||
const text = deltaText(data, 'text');
|
||||
if (text) {
|
||||
const last = assistantContext.length - 1;
|
||||
const next = `${last >= 0 ? assistantContext[last] : ''}${text}`.slice(
|
||||
-MAX_EXCERPT_CHARS,
|
||||
);
|
||||
if (last >= 0) assistantContext[last] = next;
|
||||
else assistantContext.push(next);
|
||||
if (assistantContext.length > MAX_CONTEXT_ITEMS) assistantContext.shift();
|
||||
const contextEntry = id ? textContextByStepId.get(id) : undefined;
|
||||
if (text && contextEntry != null) {
|
||||
contextEntry.text = `${contextEntry.text}${text}`.slice(-MAX_EXCERPT_CHARS);
|
||||
}
|
||||
}
|
||||
return messageHandler.handle(event, data, metadata, graph);
|
||||
|
|
@ -735,5 +1024,112 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
|
|||
return wrapped;
|
||||
};
|
||||
|
||||
return { hook, handlers: wrapHandlers, drop: clear, snapshot };
|
||||
const complete = () => {
|
||||
let finalTextIndex =
|
||||
lastRootTextStepId == null ? undefined : deps.getStepIndex?.(lastRootTextStepId);
|
||||
const parts = deps.getContentParts();
|
||||
if (
|
||||
finalTextIndex != null &&
|
||||
(parts[finalTextIndex]?.type !== ContentTypes.TEXT ||
|
||||
!textValue(parts[finalTextIndex]?.text).trim())
|
||||
) {
|
||||
finalTextIndex = undefined;
|
||||
}
|
||||
/** The host step map is an event-coordinate hint, not the authoritative
|
||||
* rendered position. Activity-label reservations advance the shared
|
||||
* content offset after earlier steps were indexed, and a provider can
|
||||
* materialize the final text at a later slot. Always reconcile against
|
||||
* the live parts so a stale-but-defined step index cannot pull the final
|
||||
* answer into the parent phase. */
|
||||
const definedIndices = Object.keys(parts);
|
||||
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
|
||||
const index = Number(definedIndices[position]);
|
||||
const part = parts[index];
|
||||
/** The UI contract is the last materialized TEXT part, not the last
|
||||
* part whose provider lane metadata happens to look root-scoped.
|
||||
* Some MCP runs retain a groupId on their final response, so even a
|
||||
* `final_answer` part may not have taken the immediate-close branch.
|
||||
* An already-closed phase has no remaining activities and completion
|
||||
* is a no-op. The later-activity checks below still reject an
|
||||
* intermediate lane text when tools or reasoning follow it. */
|
||||
if (part?.type === ContentTypes.TEXT && textValue(part.text).trim()) {
|
||||
finalTextIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
return { hook, handlers: wrapHandlers, drop: clear, complete, snapshot };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ export type TSubmission = {
|
|||
* resumes for run steps and activity labels alike.
|
||||
*/
|
||||
editPrefixLength?: number;
|
||||
/** True once server index 0 text/reasoning actually merged into the retained tail. */
|
||||
editPrefixFirstPartFolded?: boolean;
|
||||
/**
|
||||
* Set once a resume SYNC has replaced the response's retained prefix with
|
||||
* the server's completion-local snapshot. From that point the prefix is
|
||||
|
|
|
|||
|
|
@ -662,6 +662,8 @@ export type TMessageContentParts =
|
|||
tool_call_ids?: string[];
|
||||
/** Parent phase bounds and telemetry. */
|
||||
activity_start_index?: number;
|
||||
/** Exclusive end of the grouped content; may precede the marker itself. */
|
||||
activity_end_index?: number;
|
||||
activity_count?: number;
|
||||
agent_ids?: string[];
|
||||
/** ok = all tools succeeded, failed = all failed, partial = mixed. */
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export type TActivityLabelEvent = {
|
|||
activity_label_type?: 'phase';
|
||||
tool_call_ids?: string[];
|
||||
activity_start_index?: number;
|
||||
activity_end_index?: number;
|
||||
activity_count?: number;
|
||||
agent_ids?: string[];
|
||||
counts?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue