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
1 change: 1 addition & 0 deletions apps/ai/src/chat/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Several people may be in this thread and any of them can address you. Answer the
A colleague's answer in a channel, read as often on a phone as on a desktop.

- Lead with the finding. No preamble, no narration of your tool calls, no next steps unless the user asks for them
- What you write after your last tool call is what gets posted; anything you write between calls is treated as working notes and is not shown, so state findings rather than steps
- Keep prose under about 120 words. Say what is abnormal and why it matters
- Plain, standard markdown only: paragraphs, \`-\` bullets, **bold** for a key metric, \`code\` for IDs. No tables and no \`#\` headings at any level — neither survives the trip to a chat platform
- Never use an emoji as a bullet or a status marker
Expand Down
181 changes: 181 additions & 0 deletions packages/chat-platform/src/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const prose = (blocks: ReadonlyArray<ChatBlock>): string =>
.map((block) => block.markdown)
.join("\n")

/** Whatever the status line names, if a message is carrying one at all. */
const working = (blocks: ReadonlyArray<ChatBlock>): ReadonlyArray<string> =>
blocks.flatMap((block) => (block.kind === "activity" ? block.tools.map((tool) => tool.name) : []))

describe("driveChatTurn", () => {
it.effect("posts a placeholder, coalesces deltas into throttled edits, and flushes at the end", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -126,6 +130,183 @@ describe("driveChatTurn", () => {
}),
)

it.effect("shows the tool it is on while it works, and only the answer once it has", () =>
Effect.gen(function* () {
const chat = recorder()
const fiber = yield* Effect.forkChild(
driveChatTurn({
events: timeline([
[NOW, event(1, { type: "turn-start", messageId: "a1" })],
[
NOW,
event(2, {
type: "text-delta",
messageId: "a1",
text: "Let me look at the errors first.",
}),
],
[
NOW,
event(3, {
type: "tool-call",
messageId: "a1",
callId: "c1",
name: "find_errors",
input: {},
}),
],
[
Duration.millis(1500),
event(4, { type: "tool-result", messageId: "a1", callId: "c1", output: null }),
],
[
NOW,
event(5, {
type: "text-delta",
messageId: "a1",
text: " Now I'll check the traces.",
}),
],
[
NOW,
event(6, {
type: "tool-call",
messageId: "a1",
callId: "c2",
name: "search_traces",
input: {},
}),
],
[
Duration.millis(1500),
event(7, { type: "tool-result", messageId: "a1", callId: "c2", output: null }),
],
[
NOW,
event(8, {
type: "text-delta",
messageId: "a1",
text: " checkout times out on the database.",
}),
],
[
Duration.millis(1500),
event(9, { type: "turn-end", messageId: "a1", reason: "stop" }),
],
]),
messageId: "a1",
outbound: chat.outbound,
target,
context,
}),
)

yield* TestClock.adjust("6 seconds")
yield* Fiber.join(fiber)

// The first thing the reader sees after the placeholder: the tool, and none of the
// narration the model wrote on its way into it.
expect(chat.calls.find((call) => working(call.blocks).length > 0)?.blocks).toEqual([
{ kind: "activity", tools: [{ name: "find_errors", status: "running", detail: null }] },
])
const lines = chat.calls.map((call) => working(call.blocks))
// One tool at a time, never the list of everything the turn touched on its way here.
for (const line of lines) expect(line.length).toBeLessThanOrEqual(1)
// The two calls, in the order the turn made them. Repeats are a call whose status
// changed under the same name, which is one line either way.
expect(lines.flat().filter((name, index, all) => name !== all[index - 1])).toEqual([
"find_errors",
"search_traces",
])
// The words the model wrote to itself between its calls never reach the channel.
for (const call of chat.calls) expect(prose(call.blocks)).not.toMatch(/Let me|Now I'll/)
// The finished message is the answer and nothing else.
expect(chat.calls[chat.calls.length - 1].blocks).toEqual([
{ kind: "prose", markdown: "checkout times out on the database." },
])
}),
)

it.effect("says a turn that stopped without a word finished, not that it is still working", () =>
Effect.gen(function* () {
const chat = recorder()
// A model that runs a tool and then stops: the turn ends on `stop` with no prose to show
// for it, and the placeholder would otherwise be the channel's last word on the matter.
yield* driveChatTurn({
events: timeline([
[NOW, event(1, { type: "turn-start", messageId: "a1" })],
[
NOW,
event(2, {
type: "tool-call",
messageId: "a1",
callId: "c1",
name: "find_errors",
input: {},
}),
],
[NOW, event(3, { type: "tool-result", messageId: "a1", callId: "c1", output: null })],
[NOW, event(4, { type: "turn-end", messageId: "a1", reason: "stop" })],
]),
messageId: "a1",
outbound: chat.outbound,
target,
context,
})

expect(chat.calls[chat.calls.length - 1].blocks).toEqual([
{ kind: "notice", tone: "info", text: "Finished without a reply." },
])
}),
)

it.effect("names a sub-agent by the agent it delegates to on the status line", () =>
Effect.gen(function* () {
const chat = recorder()
const fiber = yield* Effect.forkChild(
driveChatTurn({
events: timeline([
[NOW, event(1, { type: "turn-start", messageId: "a1" })],
[
NOW,
event(2, {
type: "tool-call",
messageId: "a1",
callId: "t1",
name: "task_reviewer",
input: {},
}),
],
[
NOW,
event(3, {
type: "turn-start",
messageId: "s1",
task: { id: "t1", agent: "reviewer", parentMessageId: "a1" },
}),
],
[
Duration.millis(1500),
event(4, { type: "turn-end", messageId: "a1", reason: "stop" }),
],
]),
messageId: "a1",
outbound: chat.outbound,
target,
context,
}),
)

yield* TestClock.adjust("3 seconds")
yield* Fiber.join(fiber)

// The delegation is a line on the same status block, named for the agent it runs.
expect(chat.calls.find((call) => working(call.blocks).length > 0)?.blocks).toEqual([
{ kind: "activity", tools: [{ name: "reviewer", status: "running", detail: "1 step" }] },
])
}),
)

it.effect("re-renders a retracted attempt instead of leaving the text it took back", () =>
Effect.gen(function* () {
const chat = recorder()
Expand Down
25 changes: 23 additions & 2 deletions packages/chat-platform/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,25 @@ export const driveChatTurn = Effect.fn("ChatPlatform.driveChatTurn")(function* <
/** How the turn closed, for the span — the one place a failed turn is more than a line of copy. */
let endReason = "unfinished"
let dirty = false
/**
* What the render is of: a turn in progress, or the message it settles as.
*
* The exception to the note above — it is cleared by this fiber once the throttle fiber has
* been interrupted, so no flush can read it half way through a turn it has not finished.
*/
let running = true

const flush = gate.withPermit(
Effect.gen(function* () {
dirty = false
const message = transcript.messages.find((candidate) => candidate.id === messageId)
const blocks: Array<ChatBlock> =
message === undefined ? [] : [...renderChatMessage(message, context)]
message === undefined ? [] : [...renderChatMessage(message, context, running)]
if (ending !== null) blocks.push(ending)
if (blocks.length === 0) blocks.push(PENDING)
// A finished turn must never be left under the placeholder: a model that stops after a
// tool call without a closing word renders as nothing at all, and "Working on it…" would
// then be the last thing the channel ever says about it.
if (blocks.length === 0) blocks.push(running ? PENDING : NOTHING_SAID)

const groups = splitBlocks(blocks, outbound.limits.maxMessageChars)
for (let index = 0; index < Math.max(groups.length, posted.length); index++) {
Expand Down Expand Up @@ -158,6 +168,10 @@ export const driveChatTurn = Effect.fn("ChatPlatform.driveChatTurn")(function* <
Effect.exit,
)
yield* Fiber.interrupt(throttled)
// Whatever ended the turn, the last render is the finished message: the answer, and none of the
// working. Set here rather than on `turn-end`, so a stream that died — and never reaches one —
// settles the same way.
running = false
yield* flush
yield* Effect.annotateCurrentSpan({ "chat.turn.end_reason": endReason, "chat.messages": posted.length })
return yield* outcome
Expand All @@ -182,6 +196,13 @@ const NO_BLOCKS: ReadonlyArray<ChatBlock> = []

const PENDING: ChatNoticeBlock = { kind: "notice", tone: "pending", text: "Working on it…" }

/** A turn that ran to its end and wrote nothing to show for it. Rare, and never silent. */
const NOTHING_SAID: ChatNoticeBlock = {
kind: "notice",
tone: "info",
text: "Finished without a reply.",
}

const DISCONNECTED: ChatNoticeBlock = {
kind: "notice",
tone: "error",
Expand Down
Loading
Loading