Skip to content

Commit 10e709c

Browse files
authored
fix(queued-message): avoid duplicate queued message entries (#26355)
1 parent 9bc4138 commit 10e709c

5 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,6 +2191,12 @@ export function buildThreadFeed(
21912191
const item = row.item;
21922192
if (turnItemIsWorkspacePreparation(item)) continue;
21932193
if (item.type === "todo_list" || item.type === "checkpoint") continue;
2194+
if (
2195+
item.type === "user_message" &&
2196+
item.inputIntent === "queued_turn" &&
2197+
item.status === "pending"
2198+
)
2199+
continue;
21942200
if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue;
21952201
if (
21962202
item.type === "run_interrupt_request" ||

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,6 +1920,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
19201920
}),
19211921
providerThreadId: queuedProviderThread.id,
19221922
inputIntent: "queued_turn",
1923+
status: "completed",
19231924
startedAt: now,
19241925
completedAt: now,
19251926
updatedAt: now,
@@ -5749,6 +5750,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
57495750
providerThreadId: queuedProviderThread.id,
57505751
providerTurnId: null,
57515752
inputIntent: "queued_turn",
5753+
status: "pending",
5754+
startedAt: null,
5755+
completedAt: null,
57525756
updatedAt: now,
57535757
},
57545758
});

‎apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,13 @@ it.effect.each(
339339
if (delivery !== null) {
340340
yield* Deferred.succeed(rejectSteer, undefined);
341341
yield* Fiber.join(delivery);
342+
const requeued = yield* orchestrator.getThreadProjection(threadId);
343+
const item = requeued.turnItems.find(
344+
(item) => item.type === "user_message" && item.messageId === messageId,
345+
);
346+
assert.equal(item?.status, "pending");
347+
assert.isNull(item?.startedAt);
348+
assert.isNull(item?.completedAt);
342349
}
343350
if (timing === "before dispatch") yield* dispatchSteer;
344351
yield* worker.drain();
@@ -391,6 +398,14 @@ it.effect.each(
391398
]);
392399
assert.equal(steerCalls, timing === "during delivery" ? 1 : 0);
393400
const final = yield* orchestrator.getThreadProjection(threadId);
401+
if (!mailbox) {
402+
assert.equal(
403+
final.turnItems.find(
404+
(item) => item.type === "user_message" && item.messageId === messageId,
405+
)?.status,
406+
"completed",
407+
);
408+
}
394409
assert.equal(final.messages.filter((message) => message.id === messageId).length, 1);
395410
assert.equal(
396411
final.messages.find((message) => message.id === messageId)?.runId,
@@ -669,6 +684,78 @@ it.effect("sends a selected held message once and preserves the remaining queue
669684
),
670685
);
671686

687+
it.effect("force sends multiple queued messages and can force send a requeued message again", () =>
688+
Effect.scoped(
689+
Effect.gen(function* () {
690+
const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness(
691+
"multiple-promoted-messages",
692+
);
693+
yield* Effect.gen(function* () {
694+
const orchestrator = yield* Orchestrator.OrchestratorV2;
695+
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
696+
const threadId = yield* startFirstTurn;
697+
const queue = (text: string, commandId: string) =>
698+
orchestrator.dispatch({
699+
type: "message.dispatch",
700+
commandId: CommandId.make(commandId),
701+
threadId,
702+
messageId: MessageId.make(`message:${text}`),
703+
text,
704+
attachments: [],
705+
dispatchMode: { type: "queue_after_active" },
706+
createdBy: "user",
707+
creationSource: "web",
708+
});
709+
for (const text of ["one", "two", "remaining"]) yield* queue(text, `queue-${text}`);
710+
const promote = Effect.fn("test.promote")(function* (text: string, commandId: string) {
711+
const projection = yield* orchestrator.getThreadProjection(threadId);
712+
const queued = projection.runs.find(
713+
(run) =>
714+
run.status === "queued" && run.userMessageId === MessageId.make(`message:${text}`),
715+
)!;
716+
yield* orchestrator.dispatch({
717+
type: "queued-message.promote-to-steer",
718+
commandId: CommandId.make(commandId),
719+
threadId,
720+
queuedRunId: queued.id,
721+
targetRunId: started[0]!.runId,
722+
});
723+
});
724+
yield* promote("one", "promote-one");
725+
yield* promote("two", "promote-two");
726+
yield* worker.drain();
727+
assert.deepEqual(steered, ["one", "two"]);
728+
729+
// The completion-race fallback redispatches the same message into the queue.
730+
yield* queue("one", "requeue-one");
731+
const requeued = yield* orchestrator.getThreadProjection(threadId);
732+
assert.equal(
733+
requeued.turnItems.find(
734+
(item) => item.type === "user_message" && item.messageId === "message:one",
735+
)?.status,
736+
"pending",
737+
);
738+
yield* promote("one", "promote-one-again");
739+
yield* worker.drain();
740+
assert.deepEqual(steered, ["one", "two", "one"]);
741+
assert.lengthOf(started, 1);
742+
const final = yield* orchestrator.getThreadProjection(threadId);
743+
assert.deepEqual(
744+
final.runs.filter((run) => run.status === "queued").map((run) => run.userMessageId),
745+
[MessageId.make("message:remaining")],
746+
);
747+
for (const text of ["one", "two"]) {
748+
const items = final.turnItems.filter(
749+
(item) => item.type === "user_message" && item.messageId === `message:${text}`,
750+
);
751+
assert.lengthOf(items, 1);
752+
assert.equal(items[0]?.status, "completed");
753+
}
754+
}).pipe(Effect.provide(layer));
755+
}),
756+
),
757+
);
758+
672759
it.effect("keeps a message queued when send now races with a running turn", () =>
673760
Effect.scoped(
674761
Effect.gen(function* () {

‎apps/web/src/session-logic.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,27 @@ describe("V2 session presentation", () => {
672672
scheduledTaskId: ScheduledTaskId.make("task-queued"),
673673
senderThreadId: ThreadId.make("thread-agent-sender"),
674674
} satisfies OrchestrationV2TurnItem;
675+
for (const inputIntent of [
676+
"queued_turn",
677+
"turn_start",
678+
"steer",
679+
"promoted_queued_to_steer",
680+
] as const) {
681+
expect(
682+
deriveTimelineEntriesFromVisibleTurnItems({
683+
visibleTurnItems: [
684+
{
685+
position: 0,
686+
visibility: "local",
687+
sourceThreadId: projection.thread.id,
688+
sourceItemId: dispatchedItem.id,
689+
item: { ...dispatchedItem, inputIntent, status: "pending" },
690+
},
691+
],
692+
optimisticMessages: [],
693+
}),
694+
).toHaveLength(inputIntent === "queued_turn" ? 0 : 1);
695+
}
675696
const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({
676697
visibleTurnItems: [
677698
{

‎apps/web/src/session-logic.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,12 @@ export function deriveTimelineEntriesFromVisibleTurnItems(
603603
const { item } = row;
604604
if (turnItemIsWorkspacePreparation(item)) continue;
605605
if (item.type === "checkpoint") continue;
606+
if (
607+
item.type === "user_message" &&
608+
item.inputIntent === "queued_turn" &&
609+
item.status === "pending"
610+
)
611+
continue;
606612
if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue;
607613
const createdAt = projectedItemCreatedAt(row);
608614
const attempt = resolveAttempt(item);

0 commit comments

Comments
 (0)