Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions apps/discord-bot/src/features/ResponseBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
isDeliveryBehindOrchestration,
isStreamTipDisplacedByForeignMessage,
isStreamTipDisplacedByRecentMessages,
shouldHopWorkingTipOnHumanDisplacement,
isStreamTipDisplacedByEchoedCommand,
isDiscordEchoedCommandContent,
shouldHopWorkingTipOnDisplacement,
isDiscordContentMessageType,
pickLatestContentMessageId,
deliveryFailureBackoffSeconds,
Expand Down Expand Up @@ -584,8 +586,67 @@ describe("isStreamTipDisplacedByForeignMessage / content message types", () => {
});

describe("Working tip hop policy", () => {
it("does not hop Working below human chat", () => {
expect(shouldHopWorkingTipOnHumanDisplacement()).toBe(false);
it("does not hop Working below casual human chat", () => {
expect(shouldHopWorkingTipOnDisplacement("casual-chat")).toBe(false);
});

it("hops Working below an echoed desktop/web/mobile command", () => {
expect(shouldHopWorkingTipOnDisplacement("echoed-command")).toBe(true);
});
});

describe("isDiscordEchoedCommandContent", () => {
it("matches the 💭 from **user@desktop** echo prefix", () => {
expect(
isDiscordEchoedCommandContent(
"💭 from **patroza@desktop**:\nmake CUPSFake its own distinct service",
),
).toBe(true);
});

it("does not match Tasks or Working bodies", () => {
expect(isDiscordEchoedCommandContent("**Tasks 1/3**\n- [ ] wire CUPS")).toBe(false);
expect(isDiscordEchoedCommandContent("_Working.._")).toBe(false);
});
});

describe("isStreamTipDisplacedByEchoedCommand", () => {
const echo = "💭 from **patroza@desktop**:\nmake CUPSFake its own distinct service";

it("hops when an echo sits newer than Working", () => {
expect(
isStreamTipDisplacedByEchoedCommand({
recentMessagesNewestFirst: [
{ id: "echo-1", type: 0, author: { id: "bot-1" }, content: echo },
{ id: "working-tip", type: 0, author: { id: "bot-1" }, content: "_Working.._" },
],
streamTipId: "working-tip",
}),
).toBe(true);
});

it("does not hop for casual human chat below Working", () => {
expect(
isStreamTipDisplacedByEchoedCommand({
recentMessagesNewestFirst: [
{ id: "human-1", type: 0, author: { id: "user-a" }, content: "side discussion" },
{ id: "working-tip", type: 0, author: { id: "bot-1" }, content: "_Working.._" },
],
streamTipId: "working-tip",
}),
).toBe(false);
});

it("does not hop when Working is still the channel tip", () => {
expect(
isStreamTipDisplacedByEchoedCommand({
recentMessagesNewestFirst: [
{ id: "working-tip", type: 0, author: { id: "bot-1" }, content: "_Working.._" },
{ id: "echo-1", type: 0, author: { id: "bot-1" }, content: echo },
],
streamTipId: "working-tip",
}),
).toBe(false);
});
});

Expand Down Expand Up @@ -620,7 +681,7 @@ describe("isStreamTipDisplacedByRecentMessages", () => {

it("detects humans between Working and a later Tasks post", () => {
// Working → human chat → channel rename → Tasks. Latest-only missed this.
// Displacement is detected; stream/heartbeat must not hop (see hop policy).
// Human displacement is detected; hop still requires an echoed command.
expect(
isStreamTipDisplacedByRecentMessages({
recentMessagesNewestFirst: [
Expand Down
158 changes: 133 additions & 25 deletions apps/discord-bot/src/features/ResponseBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,17 @@ export type DiscordChannelMessageForTipScan = {
readonly id: string;
readonly type?: number | null;
readonly author?: { readonly id?: string | null } | null;
readonly content?: string | null;
};

/**
* Cross-surface command echoed into Discord (`💭 from **user@desktop**:` …).
* These are new commands, not bot side-posts (Tasks / pins / Working).
*/
export function isDiscordEchoedCommandContent(text: string): boolean {
return text.trim().startsWith("💭 from **");
}

/**
* Whether a human (or other non-bot-owned) *content* message sits newer than the Working tip.
*
Expand All @@ -364,11 +373,9 @@ export type DiscordChannelMessageForTipScan = {
* (title renames, pins) are skipped. A human *between* Working and a later Tasks
* post **is** displaced, even when Tasks is the channel tip.
*
* Displacement is **not** a hop. {@link shouldHopWorkingTipOnHumanDisplacement} is
* false: stream/heartbeat keep editing the original tip. Working relocates only
* when a new command posts its own ack (`shouldPostWorkingAckForContinue`: idle
* turn or `--steer` / `/agent steer`). Finalize creates a new message and deletes
* in-progress tips — that is not a hop.
* Casual-chat displacement does **not** hop Working. Hop only when an echoed
* command sits newer than the tip ({@link isStreamTipDisplacedByEchoedCommand})
* or a Discord `--steer` / idle turn posts a new Working ack.
*/
export function isStreamTipDisplacedByRecentMessages(input: {
readonly recentMessagesNewestFirst: ReadonlyArray<DiscordChannelMessageForTipScan>;
Expand Down Expand Up @@ -435,22 +442,45 @@ export function isStreamTipDisplacedByForeignMessage(input: {
}

/**
* Whether stream/heartbeat should delete `_Working.._` and re-post it below later
* human messages.
* Whether an echoed T3/GitHub command sits newer than the live Working tip.
*
* Always false. Casual thread chat and parked (queued) follow-ups must leave the
* in-progress message in place — hopping jumps Stop + progress through the
* conversation. Relocation is only a new-command Working ack (idle turn or
* `--steer` / `/agent steer` via `shouldPostWorkingAckForContinue`). `/agent
* steernow` injects the queue without a new tip.
*
* The final turn answer is never an in-place edit of Working: finalize always
* creates a new message, then deletes the in-progress tips.
* Echoes are bot-authored, so {@link isStreamTipDisplacedByRecentMessages} skips
* them. Casual Discord chat must not hop Working; a desktop/web/mobile (or
* GitHub) command echoed into the thread should.
*/
export function shouldHopWorkingTipOnHumanDisplacement(): boolean {
export function isStreamTipDisplacedByEchoedCommand(input: {
readonly recentMessagesNewestFirst: ReadonlyArray<DiscordChannelMessageForTipScan>;
readonly streamTipId: string | null;
}): boolean {
const tip = input.streamTipId?.trim() ?? "";
if (tip === "") return false;
for (const message of input.recentMessagesNewestFirst) {
if (!isDiscordContentMessageType(message.type)) continue;
const id = message.id.trim();
if (id === "") continue;
if (id === tip) return false;
if (isDiscordEchoedCommandContent(message.content ?? "")) return true;
}
return false;
}

export type WorkingTipDisplacementCause = "casual-chat" | "echoed-command";

/**
* Whether stream/heartbeat should delete `_Working.._` and re-post it below a
* later channel message.
*
* Casual thread chat and parked (queued) follow-ups leave the in-progress
* message in place. An echoed cross-surface command (desktop/web/mobile/GitHub)
* hops Working under that command. Discord `--steer` / idle turns post a new
* Working ack instead ({@link shouldPostWorkingAckForContinue}).
*
* Finalize always creates a new message, then deletes in-progress tips.
*/
export function shouldHopWorkingTipOnDisplacement(cause: WorkingTipDisplacementCause): boolean {
return cause === "echoed-command";
}

export function shouldPublishAssistantUpdate(input: {
readonly presentationMode: DiscordBridgePresentationMode;
readonly streaming: boolean;
Expand Down Expand Up @@ -2286,10 +2316,9 @@ export function planStreamTipFreezeOnDisplacement(input: {
* Move a displaced Working tip: create the replacement first, then delete the old
* message(s). Create-before-delete so Discord never sits without a Working bubble.
*
* Stream/heartbeat no longer call this ({@link shouldHopWorkingTipOnHumanDisplacement}
* is false). A steered/new-command Working ack uses
* {@link nextBridgeStateAfterAdoptWorkingAck} instead. Finalize creates a new
* message and deletes in-progress tips separately.
* Used when an echoed cross-surface command lands below Working. A Discord
* `--steer` / idle Working ack uses {@link nextBridgeStateAfterAdoptWorkingAck}
* instead. Finalize creates a new message and deletes in-progress tips separately.
*/
export function nextStateAfterMovingWorkingTip(input: {
readonly priorDiscordMessageIds: ReadonlyArray<string>;
Expand Down Expand Up @@ -3352,8 +3381,8 @@ export const runBridge = (

// Multi-step agents open a new assistant id per bubble while the turn runs.
// Keep the same Discord tip(s) and edit them — never delete/recreate mid-turn
// because humans chatted (shouldHopWorkingTipOnHumanDisplacement is false).
// Working relocates only when a new command posts its own ack (steer / idle).
// because humans chatted. Working hops below an echoed command, or relocates
// when a Discord `--steer` / idle turn posts a new Working ack.
// Working.. ack (if seeded) is already in discordMessageIds.
// On a new delivery, only keep the newest tip slot (the Working ack) so we never
// try to edit prior-turn message ids (Discord 10008 Unknown Message).
Expand Down Expand Up @@ -4710,6 +4739,74 @@ export const runBridge = (
});
});

/**
* Re-post the live Working tip at the channel end (create first, then delete
* the old one). Used when an echoed desktop/web/mobile/GitHub command landed
* below Working.
*/
const hopWorkingTipToChannelEnd = (reason: string) =>
Effect.gen(function* () {
const state = yield* Ref.get(stateRef);
const tipId = state.discordMessageIds.at(-1) ?? null;
if (tipId === null) return;
const latest = yield* Ref.get(latestThreadRef);
const turnRunning = latest !== null && isTurnInProgress(latest);
const hb = decideHeartbeat({
state: state.delivery,
turnInProgress: turnRunning,
hasOpenTip: true,
});
if (hb._tag === "noop") return;

const toolCallCount = currentTurnToolCallCount(latest);
const tipDisplay = rewriteMarkdownTablesForDiscord(streamDisplayText(hb.tipBody));
const chunks =
tipDisplay.trim() === ""
? ([""] as string[])
: chunkDiscordContent(tipDisplay, STREAM_CHUNK_LIMIT);
const tipChunk = chunks.at(-1) ?? "";
const hopContent = formatInProgressChunk(tipChunk, true, DISCORD_LIMIT, 2, toolCallCount);
const created = yield* rest.createMessage(input.discordChannelId, {
...workingMessageFields(hopContent, input.t3ThreadId),
});
const moved = nextStateAfterMovingWorkingTip({
priorDiscordMessageIds: [tipId],
priorStaleStreamMessageIds: state.staleStreamMessageIds,
newTipIds: [...state.discordMessageIds.filter((id) => id !== tipId), created.id],
});
yield* deleteMessages(moved.oldIdsToDelete).pipe(
Effect.catchCause(Effect.logWarning),
Effect.asVoid,
);
yield* Ref.update(stateRef, (current) => ({
...current,
discordMessageIds: [...moved.discordMessageIds],
staleStreamMessageIds: [...moved.staleStreamMessageIds],
streamBreakPrefix: moved.streamBreakPrefix,
}));
const afterHop = yield* Ref.get(stateRef);
yield* persistStreamMessageIds(allStreamIds(afterHop));
yield* Effect.logInfo("Hopped Working tip below echoed command", {
t3ThreadId: input.t3ThreadId,
reason,
deletedDiscordMessageId: tipId,
messageId: created.id,
toolCallCount,
});
});

const workingTipIsAboveEchoedCommand = (streamTipId: string | null) =>
Effect.gen(function* () {
if (streamTipId === null || streamTipId.trim() === "") return false;
const recent = yield* rest
.listMessages(input.discordChannelId, { limit: 15 })
.pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<DiscordChannelMessageForTipScan>));
return isStreamTipDisplacedByEchoedCommand({
recentMessagesNewestFirst: recent,
streamTipId,
});
});

const updateWorkingHeartbeat = Effect.fn("ResponseBridge.updateWorkingHeartbeat")(function* (
workingDots: WorkingDotCount,
) {
Expand Down Expand Up @@ -4765,9 +4862,15 @@ export const runBridge = (
return;
}

// Keep editing the original Working tip in place. Human chat must not hop it
// (shouldHopWorkingTipOnHumanDisplacement). A steered/new-command ack posts
// its own tip via adoptWorkingAckMessageId.
// Echoed desktop/web/mobile/GitHub command below Working: hop the live tip
// under that command. Casual thread chat must not hop.
if (
shouldHopWorkingTipOnDisplacement("echoed-command") &&
(yield* workingTipIsAboveEchoedCommand(tipId))
) {
yield* hopWorkingTipToChannelEnd("heartbeat-echoed-command");
return;
}

// Epoch FSM: awaiting → dots only; streaming → current epoch streamText only.
// Same GFM→bullets rewrite as the primary stream path.
Expand Down Expand Up @@ -5292,6 +5395,11 @@ export const runBridge = (
Effect.catchCause(Effect.logError),
Effect.asVoid,
);
if (shouldHopWorkingTipOnDisplacement("echoed-command")) {
yield* streamWriteLock
.withPermit(hopWorkingTipToChannelEnd("external-user-echo"))
.pipe(Effect.catchCause(Effect.logWarning), Effect.asVoid);
}
}

const pending = derivePendingInteractions(thread.activities);
Expand Down
4 changes: 2 additions & 2 deletions apps/discord-bot/src/presentation/mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ describe("shouldPostWorkingAckForContinue", () => {
});

it("posts Working for a mid-turn steer and for idle continues", () => {
// Only these cases relocate the in-progress message (new Working ack under
// the command). Queued follow-ups and human chat leave the live tip in place.
// Discord-originated relocate: new Working ack under the command. Queued
// follow-ups leave the live tip; echoed desktop commands hop separately.
expect(
shouldPostWorkingAckForContinue({
turnAlreadyRunning: true,
Expand Down
5 changes: 3 additions & 2 deletions apps/discord-bot/src/presentation/mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ export function resolveDiscordFollowUpDelivery(
/**
* Whether a continue-mention should post/adopt a fresh `_Working.._` tip.
*
* This is the **only** path that relocates Working mid-turn. Stream/heartbeat
* must not hop the live tip below human chat.
* Discord-originated relocate path (idle turn or `--steer`). Stream/heartbeat
* also hop Working below an echoed desktop/web/mobile/GitHub command. Casual
* thread chat must not hop the live tip.
*
* Parked mid-turn follow-ups must not post a new tip. Adopting a new Working
* freezes the live tip and bumps the delivery epoch, so the in-flight answer is
Expand Down
Loading