mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(task-chat): place a steered yielded reply in its own segment
A steering anchor can split a yielded turn. The authored reply now renders in the last segment that contains an authored final, not always in the last populated segment. The settled final-response choice moves from a nested ternary into a small helper, and the runner turn's suppressFinal prop is renamed awaitingContinuation, since it no longer suppresses authored prose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c134d72620
commit
4f300fbb3e
4 files changed
+117
-35
No files matched your search
@@ -3267,6 +3267,70 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a steered yielded turn's reply in the segment that wrote it", () => {
|
||||
const runId = "steered-interview";
|
||||
nativeTranscriptState.transcriptByRun.set(runId, [
|
||||
assistantEntry("2026-08-25T18:00:02.000Z", "Here is the question card I just posted for you.", "final"),
|
||||
assistantEntry("2026-08-25T18:00:10.000Z", "Folding in the steering note.", "progress"),
|
||||
]);
|
||||
renderThread({
|
||||
comments: [
|
||||
{
|
||||
id: "steer",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Steering note from the user.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: null,
|
||||
consumedByRunId: runId,
|
||||
steeredIntoRunId: runId,
|
||||
conversationAnchorAt: new Date("2026-08-25T18:00:05.000Z"),
|
||||
createdAt: new Date("2026-08-25T18:00:05.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:00:05.000Z"),
|
||||
},
|
||||
],
|
||||
onAdd: async () => {},
|
||||
issueStatus: "in_progress",
|
||||
interactions: [
|
||||
{
|
||||
...questionInteraction("steered-card", "Pick one", "2026-08-25T18:00:03.000Z"),
|
||||
sourceRunId: runId,
|
||||
} as IssueThreadInteraction,
|
||||
],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId,
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:20.000Z",
|
||||
resultJson: {
|
||||
nativeResult: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "yielded",
|
||||
summary: "Waiting for Pick one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const text = container.textContent ?? "";
|
||||
const replyAt = text.indexOf("Here is the question card I just posted");
|
||||
const steerAt = text.indexOf("Steering note from the user.");
|
||||
expect(replyAt).toBeGreaterThanOrEqual(0);
|
||||
expect(steerAt).toBeGreaterThanOrEqual(0);
|
||||
expect(replyAt).toBeLessThan(steerAt);
|
||||
expect(text.split("Here is the question card I just posted")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a yielded question turn out of the final-response slot", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-question", [
|
||||
assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"),
|
||||
|
||||
@@ -2108,35 +2108,53 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A yielded turn's authored reply renders once, in the segment that
|
||||
// contains it: the last segment with an authored final, which is where
|
||||
// the whole-run scan found it. A steering anchor can split the turn, so
|
||||
// the last populated segment is not always the right one.
|
||||
const yieldedOptions = {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
allowFallback: false,
|
||||
yielded: true,
|
||||
waitSummary: acceptedSummary,
|
||||
};
|
||||
const yieldedResponseSegmentIndex = sourceYieldedResponse
|
||||
? projectedSegments.findLastIndex(({ parsed }) =>
|
||||
Boolean(paperclipRunnerFinalResponse(parsed, yieldedOptions)))
|
||||
: -1;
|
||||
const settledSegmentFinalResponse = (
|
||||
segmentIndex: number,
|
||||
parsed: readonly TaskChatItem[],
|
||||
): TaskChatMessageItem | undefined => {
|
||||
if (!sourceIsPaperclipRunner) return undefined;
|
||||
const lastSegment = segmentIndex === lastPopulatedSegmentIndex;
|
||||
if (sourceYielded) {
|
||||
if (!sourceYieldedResponse) return undefined;
|
||||
if (yieldedResponseSegmentIndex === -1) return lastSegment ? sourceYieldedResponse : undefined;
|
||||
return segmentIndex === yieldedResponseSegmentIndex
|
||||
? paperclipRunnerFinalResponse(parsed, yieldedOptions)
|
||||
: undefined;
|
||||
}
|
||||
if (sourceHasPresentationComment) return undefined;
|
||||
// A steering anchor may split acceptance from its terminal. Keep the
|
||||
// whole-run proof and render its answer exactly once.
|
||||
if (sourceAcceptedResponseWake) {
|
||||
return lastSegment
|
||||
? paperclipRunnerFinalResponse(parsedSource, { runId: source.id, agentName: meta?.agentName })
|
||||
: undefined;
|
||||
}
|
||||
return paperclipRunnerFinalResponse(parsed, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
fallbackSummary: lastSegment ? acceptedSummary : undefined,
|
||||
allowFallback: sourceAllowSummaryFallback,
|
||||
});
|
||||
};
|
||||
for (const [segmentIndex, projected] of projectedSegments.entries()) {
|
||||
const { segment, parsed } = projected;
|
||||
if (segment.entries.length === 0) continue;
|
||||
const finalResponse =
|
||||
sourceIsPaperclipRunner && sourceYielded
|
||||
? segmentIndex === lastPopulatedSegmentIndex
|
||||
? sourceYieldedResponse
|
||||
: undefined
|
||||
: sourceIsPaperclipRunner &&
|
||||
!sourceHasPresentationComment
|
||||
? sourceAcceptedResponseWake
|
||||
? // A steering anchor may split acceptance from its terminal.
|
||||
// Keep the whole-run proof and render its answer exactly once.
|
||||
segmentIndex === lastPopulatedSegmentIndex
|
||||
? paperclipRunnerFinalResponse(parsedSource, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
})
|
||||
: undefined
|
||||
: paperclipRunnerFinalResponse(parsed, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
fallbackSummary:
|
||||
segmentIndex === lastPopulatedSegmentIndex
|
||||
? acceptedSummary
|
||||
: undefined,
|
||||
allowFallback: sourceAllowSummaryFallback,
|
||||
})
|
||||
: undefined;
|
||||
const finalResponse = settledSegmentFinalResponse(segmentIndex, parsed);
|
||||
const runHealth = {
|
||||
failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [],
|
||||
rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest,
|
||||
@@ -2451,7 +2469,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
? runs.find((run) => run.id === tailRunId)
|
||||
: undefined;
|
||||
const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource);
|
||||
const suppressPaperclipRunnerTailFinal = Boolean(
|
||||
const paperclipRunnerTailAwaitingContinuation = Boolean(
|
||||
paperclipRunnerTail &&
|
||||
(acceptedSemanticResultDisposition(
|
||||
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
||||
@@ -3401,8 +3419,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
activityUnavailable={tailActivityUnavailable}
|
||||
suppressFinal={
|
||||
suppressPaperclipRunnerTailFinal
|
||||
awaitingContinuation={
|
||||
paperclipRunnerTailAwaitingContinuation
|
||||
}
|
||||
waitSummary={acceptedSemanticResultSummary(
|
||||
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
item: TaskChatRuntimeRequestItem,
|
||||
decision: TaskChatRuntimeRequestDecision,
|
||||
) => void,
|
||||
suppressFinal = false,
|
||||
awaitingContinuation = false,
|
||||
continuedAfterSteering = false,
|
||||
execution?: ExecutionProjection,
|
||||
preparationLabel?: string,
|
||||
@@ -58,7 +58,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
execution={execution}
|
||||
preparationLabel={preparationLabel}
|
||||
startedAtMs={Date.now() - 2_000}
|
||||
suppressFinal={suppressFinal}
|
||||
awaitingContinuation={awaitingContinuation}
|
||||
continuedAfterSteering={continuedAfterSteering}
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
|
||||
@@ -158,7 +158,7 @@ export function TaskChatRunnerTurn({
|
||||
finishedAtMs,
|
||||
activityUnavailable = false,
|
||||
allowSummaryFallback = true,
|
||||
suppressFinal = false,
|
||||
awaitingContinuation = false,
|
||||
waitSummary = null,
|
||||
continuedAfterSteering = false,
|
||||
showHeader = true,
|
||||
@@ -183,7 +183,7 @@ export function TaskChatRunnerTurn({
|
||||
* provider final that only restates the wait is not presented as a reply.
|
||||
* Prose the agent wrote for the user stays visible.
|
||||
*/
|
||||
suppressFinal?: boolean;
|
||||
awaitingContinuation?: boolean;
|
||||
/** The accepted yielded result's wait summary, when known. */
|
||||
waitSummary?: string | null;
|
||||
/** The visible tail resumes the same native run after an accepted steer. */
|
||||
@@ -204,7 +204,7 @@ export function TaskChatRunnerTurn({
|
||||
item.surface === "run_result" &&
|
||||
item.disposition === "yielded",
|
||||
);
|
||||
const observedFinal = paperclipRunnerFinalResponse(turnItems, suppressFinal
|
||||
const observedFinal = paperclipRunnerFinalResponse(turnItems, awaitingContinuation
|
||||
? { allowFallback: false, yielded: true, waitSummary }
|
||||
: { allowFallback: terminal && allowSummaryFallback });
|
||||
const observedProviderText = Boolean(
|
||||
@@ -229,7 +229,7 @@ export function TaskChatRunnerTurn({
|
||||
// A provider final can arrive before the accepted yielded result. Clear any
|
||||
// replay latch once the control plane establishes that this turn is waiting
|
||||
// for continuation; only an authored (non-wait) final is re-latched below.
|
||||
if (yielded || suppressFinal) finalRef.current = { runId };
|
||||
if (yielded || awaitingContinuation) finalRef.current = { runId };
|
||||
if (
|
||||
observedFinal &&
|
||||
(!finalRef.current.item ||
|
||||
|
||||
Reference in new issue
Block a user