mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +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", () => {
|
it("keeps a yielded question turn out of the final-response slot", () => {
|
||||||
nativeTranscriptState.transcriptByRun.set("native-question", [
|
nativeTranscriptState.transcriptByRun.set("native-question", [
|
||||||
assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"),
|
assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"),
|
||||||
|
|||||||
@@ -2108,35 +2108,53 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
break;
|
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()) {
|
for (const [segmentIndex, projected] of projectedSegments.entries()) {
|
||||||
const { segment, parsed } = projected;
|
const { segment, parsed } = projected;
|
||||||
if (segment.entries.length === 0) continue;
|
if (segment.entries.length === 0) continue;
|
||||||
const finalResponse =
|
const finalResponse = settledSegmentFinalResponse(segmentIndex, parsed);
|
||||||
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 runHealth = {
|
const runHealth = {
|
||||||
failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [],
|
failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [],
|
||||||
rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest,
|
rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest,
|
||||||
@@ -2451,7 +2469,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
? runs.find((run) => run.id === tailRunId)
|
? runs.find((run) => run.id === tailRunId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource);
|
const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource);
|
||||||
const suppressPaperclipRunnerTailFinal = Boolean(
|
const paperclipRunnerTailAwaitingContinuation = Boolean(
|
||||||
paperclipRunnerTail &&
|
paperclipRunnerTail &&
|
||||||
(acceptedSemanticResultDisposition(
|
(acceptedSemanticResultDisposition(
|
||||||
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
||||||
@@ -3401,8 +3419,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||||||
startedAtMs={tailStartedAtMs}
|
startedAtMs={tailStartedAtMs}
|
||||||
finishedAtMs={tailFinishedAtMs}
|
finishedAtMs={tailFinishedAtMs}
|
||||||
activityUnavailable={tailActivityUnavailable}
|
activityUnavailable={tailActivityUnavailable}
|
||||||
suppressFinal={
|
awaitingContinuation={
|
||||||
suppressPaperclipRunnerTailFinal
|
paperclipRunnerTailAwaitingContinuation
|
||||||
}
|
}
|
||||||
waitSummary={acceptedSemanticResultSummary(
|
waitSummary={acceptedSemanticResultSummary(
|
||||||
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("TaskChatRunnerTurn", () => {
|
|||||||
item: TaskChatRuntimeRequestItem,
|
item: TaskChatRuntimeRequestItem,
|
||||||
decision: TaskChatRuntimeRequestDecision,
|
decision: TaskChatRuntimeRequestDecision,
|
||||||
) => void,
|
) => void,
|
||||||
suppressFinal = false,
|
awaitingContinuation = false,
|
||||||
continuedAfterSteering = false,
|
continuedAfterSteering = false,
|
||||||
execution?: ExecutionProjection,
|
execution?: ExecutionProjection,
|
||||||
preparationLabel?: string,
|
preparationLabel?: string,
|
||||||
@@ -58,7 +58,7 @@ describe("TaskChatRunnerTurn", () => {
|
|||||||
execution={execution}
|
execution={execution}
|
||||||
preparationLabel={preparationLabel}
|
preparationLabel={preparationLabel}
|
||||||
startedAtMs={Date.now() - 2_000}
|
startedAtMs={Date.now() - 2_000}
|
||||||
suppressFinal={suppressFinal}
|
awaitingContinuation={awaitingContinuation}
|
||||||
continuedAfterSteering={continuedAfterSteering}
|
continuedAfterSteering={continuedAfterSteering}
|
||||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function TaskChatRunnerTurn({
|
|||||||
finishedAtMs,
|
finishedAtMs,
|
||||||
activityUnavailable = false,
|
activityUnavailable = false,
|
||||||
allowSummaryFallback = true,
|
allowSummaryFallback = true,
|
||||||
suppressFinal = false,
|
awaitingContinuation = false,
|
||||||
waitSummary = null,
|
waitSummary = null,
|
||||||
continuedAfterSteering = false,
|
continuedAfterSteering = false,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
@@ -183,7 +183,7 @@ export function TaskChatRunnerTurn({
|
|||||||
* provider final that only restates the wait is not presented as a reply.
|
* provider final that only restates the wait is not presented as a reply.
|
||||||
* Prose the agent wrote for the user stays visible.
|
* Prose the agent wrote for the user stays visible.
|
||||||
*/
|
*/
|
||||||
suppressFinal?: boolean;
|
awaitingContinuation?: boolean;
|
||||||
/** The accepted yielded result's wait summary, when known. */
|
/** The accepted yielded result's wait summary, when known. */
|
||||||
waitSummary?: string | null;
|
waitSummary?: string | null;
|
||||||
/** The visible tail resumes the same native run after an accepted steer. */
|
/** The visible tail resumes the same native run after an accepted steer. */
|
||||||
@@ -204,7 +204,7 @@ export function TaskChatRunnerTurn({
|
|||||||
item.surface === "run_result" &&
|
item.surface === "run_result" &&
|
||||||
item.disposition === "yielded",
|
item.disposition === "yielded",
|
||||||
);
|
);
|
||||||
const observedFinal = paperclipRunnerFinalResponse(turnItems, suppressFinal
|
const observedFinal = paperclipRunnerFinalResponse(turnItems, awaitingContinuation
|
||||||
? { allowFallback: false, yielded: true, waitSummary }
|
? { allowFallback: false, yielded: true, waitSummary }
|
||||||
: { allowFallback: terminal && allowSummaryFallback });
|
: { allowFallback: terminal && allowSummaryFallback });
|
||||||
const observedProviderText = Boolean(
|
const observedProviderText = Boolean(
|
||||||
@@ -229,7 +229,7 @@ export function TaskChatRunnerTurn({
|
|||||||
// A provider final can arrive before the accepted yielded result. Clear any
|
// A provider final can arrive before the accepted yielded result. Clear any
|
||||||
// replay latch once the control plane establishes that this turn is waiting
|
// replay latch once the control plane establishes that this turn is waiting
|
||||||
// for continuation; only an authored (non-wait) final is re-latched below.
|
// 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 (
|
if (
|
||||||
observedFinal &&
|
observedFinal &&
|
||||||
(!finalRef.current.item ||
|
(!finalRef.current.item ||
|
||||||
|
|||||||
Reference in new issue
Block a user