Skip to content
76 changes: 76 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,82 @@ describe("buildThreadFeed", () => {
"thinking:step",
]);
expect(rows([user, step, passed], "zen", running)).toEqual(["user", "thinking:step"]);
// A question keeps the run working; the reply written before it is what the user answers.
const asking: OrchestrationV2TurnItem = {
...base("asking", at(6), 5),
type: "dynamic_tool",
toolName: "AskUserQuestion",
status: "completed",
input: {},
output: {},
};
const questionRequestId = RuntimeRequestId.make("question");
const question = (answered: boolean): OrchestrationV2TurnItem => ({
...base("question", at(7), 6),
type: "user_input_request",
requestId: questionRequestId,
questions: [],
...(answered
? {
questionAnswer: {
requestId: questionRequestId,
answers: { color: "Blue" },
attachmentsByQuestionId: {},
},
}
: {}),
});
expect(rows([user, step, passed, final], "zen", running)).not.toContain("final");
expect(rows([user, step, passed, final, asking, question(false)], "zen", running)).toEqual([
"user",
"final",
"thinking:",
]);
// Answering keeps the run working, and the reply stays above the answered question.
expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).toEqual([
"user",
"final",
"question",
"thinking:",
]);
// Agents often run a command between the reply and the question; the reply still shows.
expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([
"user",
"step",
"question",
"thinking:",
]);
// Secret and link requests ask the user too; their cards keep the reply above them.
const secret: OrchestrationV2TurnItem = {
...base("secret", at(7), 6),
type: "secret_request",
label: "API key",
reason: "Needed to call the API",
secretStatus: "pending",
};
const link: OrchestrationV2TurnItem = {
...base("link", at(7), 6),
type: "link_request",
reason: "Needed to run on the VPS",
linkStatus: "pending",
};
for (const request of [secret, link]) {
expect(rows([user, step, passed, final, asking, request], "zen", running)).toEqual([
"user",
"final",
request.id,
"thinking:",
]);
}
const later: OrchestrationV2TurnItem = {
...base("later", at(8), 7),
type: "reasoning",
streaming: false,
text: "later",
};
expect(
rows([user, step, passed, final, asking, question(true), later], "zen", running),
).toEqual(["user", "final", "question", "thinking:later"]);
expect(rows(turn, "zen", { ...running, turnEnded: true })).toEqual(["user", "final"]);
const nextRun = { ...running, runId: RunId.make("run-2") };
const followUp = { ...say("follow-up", 6, true), runId: nextRun.runId };
Expand Down
48 changes: 43 additions & 5 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,35 @@ function startsResponse(entry: ThreadFeedEntry): boolean {
);
}

/** A question, secret, or link request: the agent asks the user, pending or answered. */
function activityAsksUser(activity: ThreadFeedActivity): boolean {
const type = activity.projectedItem.item.type;
return type === "user_input_request" || type === "secret_request" || type === "link_request";
}

/**
* The last reply an agent wrote before it asked the user something: zen keeps it, since the run
* still works while it waits and after the answer. Agents often run a command or a tool between
* the reply and the request, so only a user message ends the search.
*/
function repliesBeforeAsks(
feed: ReadonlyArray<ThreadFeedEntry>,
start: number,
): ReadonlySet<MessageId> {
const ids = new Set<MessageId>();
let reply: MessageId | null = null;
for (let index = start; index < feed.length; index += 1) {
const entry = feed[index]!;
if (entry.type === "message") {
reply = entry.message.role === "assistant" ? entry.message.id : null;
} else if (entry.type === "activity-group" && entry.activities.some(activityAsksUser)) {
if (reply !== null) ids.add(reply);
reply = null;
}
}
return ids;
}

function zenAnswerMessageIds(
feed: ReadonlyArray<ThreadFeedEntry>,
start: number,
Expand All @@ -1535,6 +1564,7 @@ function zenAnswerMessageIds(
const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = [];
const lastReplyByResponse = new Map<string, MessageId>();
const keptAnswers = new Set(finalAnswerIds(feed));
const askReplies = repliesBeforeAsks(feed, start);
let userMessages = 0;
let awaitingReply = false;
let candidate: MessageId | null = null;
Expand Down Expand Up @@ -1570,6 +1600,7 @@ function zenAnswerMessageIds(
.filter(
({ message, index }) =>
keptAnswers.has(message.id) ||
askReplies.has(message.id) ||
(terminal.has(message.id) &&
index < answeringFrom &&
(answeringRunId === null || message.runId !== answeringRunId)),
Expand All @@ -1582,18 +1613,25 @@ function liveAnswerPreview(feed: ReadonlyArray<ThreadFeedEntry>): string | null
for (let index = feed.length - 1; index >= 0; index -= 1) {
const entry = feed[index]!;
if (startsResponse(entry)) return null;
const texts =
// The reply before a question shows in full; the preview only shows newer text.
const texts: Array<string | null> =
entry.type === "message"
? entry.message.role === "assistant"
? [entry.message.text]
: []
: entry.type === "activity-group"
? entry.activities
.filter((activity) => activity.workEntry.itemType === "reasoning")
.map((activity) => activity.workEntry.detail ?? "")
? entry.activities.flatMap((activity) =>
activityAsksUser(activity)
? [null]
: activity.workEntry.itemType === "reasoning"
? [activity.workEntry.detail ?? ""]
: [],
)
: [];
for (let textIndex = texts.length - 1; textIndex >= 0; textIndex -= 1) {
const line = compactWorkEntryText(liveThoughtLine(texts[textIndex]!));
const text = texts[textIndex]!;
if (text === null) return null;
const line = compactWorkEntryText(liveThoughtLine(text));
if (line !== "") return line;
}
}
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3081,6 +3081,98 @@ describe("v2 run and attempt history", () => {
"live-activity-row",
]);
expect(liveAnswerPreview([user, step, passed])).toBe("step");
// A question keeps the run working; the reply written before it is what the user answers.
const asking: TimelineEntry = {
id: "asking",
kind: "work",
createdAt: at(21),
entry: {
id: "asking",
createdAt: at(21),
runId,
label: "AskUserQuestion",
tone: "tool",
itemType: "dynamic_tool",
toolLifecycleStatus: "completed",
},
};
const questionRequestId = RuntimeRequestId.make("question");
const question = (answered: boolean): TimelineEntry => ({
id: "question",
kind: "work",
createdAt: at(22),
entry: {
id: "question",
createdAt: at(22),
runId,
label: answered ? "Answered questions" : "Input requested",
tone: "info",
itemType: "user_input_request",
...(answered
? {
questionAnswer: {
requestId: questionRequestId,
answers: { color: "Blue" },
attachmentsByQuestionId: {},
},
}
: {}),
},
});
const secret: TimelineEntry = {
id: "secret",
kind: "event",
createdAt: at(22),
projectedItem: { item: { type: "secret_request", runId } } as never,
};
expect(rows([user, step, passed, final], "zen", running)).not.toContain("final");
expect(rows([user, step, passed, final, asking, question(false)], "zen", running)).toContain(
"final",
);
// Answering keeps the run working, and the reply stays above the answered question.
expect(rows([user, step, passed, final, asking, question(true)], "zen", running)).toEqual([
"user",
"working-indicator-row",
"final",
"question",
"live-activity-row",
]);
const link: TimelineEntry = {
id: "link",
kind: "event",
createdAt: at(22),
projectedItem: { item: { type: "link_request", runId } } as never,
};
for (const request of [secret, link]) {
expect(rows([user, step, passed, final, asking, request], "zen", running)).toContain("final");
expect(liveAnswerPreview([user, step, passed, final, asking, request])).toBeNull();
}
// Agents often run a command between the reply and the question; the reply still shows.
expect(rows([user, step, passed, asking, question(true)], "zen", running)).toEqual([
"user",
"working-indicator-row",
"step",
"question",
"live-activity-row",
]);
expect(liveAnswerPreview([user, step, passed, final, asking, question(true)])).toBeNull();
const later: TimelineEntry = {
id: "later",
kind: "work",
createdAt: at(23),
entry: {
id: "later",
createdAt: at(23),
runId,
label: "Thinking",
tone: "thinking",
itemType: "reasoning",
detail: "later",
},
};
expect(liveAnswerPreview([user, step, passed, final, asking, question(true), later])).toBe(
"later",
);
expect(rows(turn, "zen", ended)).toEqual(["user", "final"]);
const followUp = message("follow-up", "user", 30);
expect(rows([...turn, followUp], "zen", ended)).toEqual([
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,10 +1440,39 @@ function deriveInFlightSteerAnswerIds(entries: ReadonlyArray<TimelineEntry>): Re
return ids;
}

/** A question, secret, or link request: the agent asks the user, pending or answered. */
function timelineEntryAsksUser(entry: TimelineEntry): boolean {
if (entry.kind === "work") return entry.entry.itemType === "user_input_request";
if (entry.kind !== "event") return false;
const type = entry.projectedItem.item.type;
return type === "secret_request" || type === "link_request";
}

/**
* The last reply an agent wrote before it asked the user something: zen keeps it, since the run
* still works while it waits and after the answer. Agents often run a command or a tool between
* the reply and the request, so only a user message ends the search.
*/
function deriveRepliesBeforeAsks(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
const ids = new Set<string>();
let reply: string | null = null;
for (const entry of entries) {
if (timelineEntryAsksUser(entry)) {
if (reply !== null) ids.add(reply);
reply = null;
} else if (entry.kind === "message") {
reply = entry.message.role === "assistant" ? entry.message.id : null;
}
}
return ids;
}

export function liveAnswerPreview(entries: ReadonlyArray<TimelineEntry>): string | null {
const boundary = lastResponseBoundaryIndex(entries);
for (let index = entries.length - 1; index > boundary; index -= 1) {
const entry = entries[index]!;
// The reply before a question shows in full; the preview only shows newer text.
if (timelineEntryAsksUser(entry)) return null;
const text =
entry.kind === "message" && entry.message.role === "assistant"
? entry.message.text
Expand Down Expand Up @@ -1492,6 +1521,7 @@ function timelineRowEntries(
const answeringFrom = answering ? lastResponseBoundaryIndex(entries) + 1 : entries.length;
const terminalIds = deriveTerminalAssistantMessageIds(entries);
const steerAnswerIds = deriveInFlightSteerAnswerIds(entries);
const askReplyIds = deriveRepliesBeforeAsks(entries);
const retainedReplyIds = retainedAssistantReplyEntryIds(entries);
shown = entries.filter((entry, index) => {
if (entry.kind !== "message") {
Expand All @@ -1502,6 +1532,7 @@ function timelineRowEntries(
message.role !== "assistant" ||
message.id === input.revealedMessageId ||
steerAnswerIds.has(message.id) ||
askReplyIds.has(message.id) ||
retainedReplyIds.has(entry.id) ||
(terminalIds.has(message.id) &&
index < answeringFrom &&
Expand Down
Loading