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
61 changes: 38 additions & 23 deletions packages/app/src/pages/session/message-timeline.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ mock.module("@deepagent-code/ui/message-part", () => ({
type: "part",
ref: { messageID: item.messageID, partID: item.part.id },
})),
renderable: () => true,
renderable: (part: Part, showReasoningSummaries = true) =>
part.type !== "reasoning" || showReasoningSummaries,
}))

afterAll(() => mock.restore())
Expand Down Expand Up @@ -122,7 +123,7 @@ describe("message timeline activity progress", () => {
},
}) as Part

test("shows only the latest settled progress for one activity", async () => {
test("renders every progress revision for one activity", async () => {
const { Timeline } = await import("./message-timeline.data")
const messages = [assistant("msg_a0"), assistant("msg_a1")]
const parts = new Map([
Expand All @@ -133,10 +134,10 @@ describe("message timeline activity progress", () => {
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false)
expect(
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
).toEqual(["prt_progress_1"])
).toEqual(["prt_progress_0", "prt_progress_1"])
})

test("replaces settled progress with the activity final", async () => {
test("keeps progress revisions when the activity final arrives", async () => {
const { Timeline } = await import("./message-timeline.data")
const messages = [assistant("msg_a0"), assistant("msg_a1"), { ...assistant("msg_a2"), finish: "stop" }]
const parts = new Map([
Expand All @@ -148,10 +149,10 @@ describe("message timeline activity progress", () => {
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false)
expect(
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
).toEqual(["prt_final_2"])
).toEqual(["prt_progress_0", "prt_progress_1", "prt_final_2"])
})

test("collapses every text part in one activity across separate parent user rows", async () => {
test("keeps text parts across separate parent user rows", async () => {
const { Timeline } = await import("./message-timeline.data")
const user2 = { ...user, id: "msg_user_2" }
const firstAssistant = assistant("msg_cross_a0")
Expand All @@ -173,7 +174,6 @@ describe("message timeline activity progress", () => {
],
])
const getParts = (id: string) => parts.get(id) ?? []
const visibility = Timeline.activityProgressVisibility(messages, getParts)
const firstRows = Timeline.constructMessageRows(
user,
getParts,
Expand All @@ -182,7 +182,6 @@ describe("message timeline activity progress", () => {
false,
"idle",
false,
visibility,
)
const secondRows = Timeline.constructMessageRows(
user2,
Expand All @@ -192,17 +191,20 @@ describe("message timeline activity progress", () => {
false,
"idle",
false,
visibility,
)
expect(firstRows.some((row) => row._tag === "AssistantPart")).toBe(false)
expect(
firstRows.flatMap((row) =>
row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [],
),
).toEqual(["prt_progress_0", "prt_cross_old_plain"])
expect(
secondRows.flatMap((row) =>
row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [],
),
).toEqual(["prt_progress_1", "prt_cross_latest_plain"])
})

test("applies one revision marker to every text part in the assistant message", async () => {
test("keeps every text part in every revision", async () => {
const { Timeline } = await import("./message-timeline.data")
const messages = [assistant("msg_multi_a0"), { ...assistant("msg_multi_a1"), finish: "stop" }]
const plain = (messageID: string, id: string, text: string) =>
Expand All @@ -219,10 +221,10 @@ describe("message timeline activity progress", () => {

expect(
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
).toEqual(["prt_final_1", "prt_final_plain"])
).toEqual(["prt_progress_0", "prt_old_plain", "prt_final_1", "prt_final_plain"])
})

test("uses the message marker to collapse reasoning-only revisions", async () => {
test("keeps reasoning summaries from every revision when enabled", async () => {
const { Timeline } = await import("./message-timeline.data")
const messages = [
{
Expand All @@ -245,19 +247,34 @@ describe("message timeline activity progress", () => {

expect(
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
).toEqual(["prt_reasoning_latest"])
).toEqual(["prt_reasoning_old", "prt_reasoning_latest"])
})

test("hides an older tool revision when the latest terminal message has no renderable parts", async () => {
test("uses showReasoning as the explicit reasoning visibility control", async () => {
const { Timeline } = await import("./message-timeline.data")
const message = assistant("msg_reasoning_setting")
const reasoning = {
id: "prt_reasoning_setting",
sessionID: user.sessionID,
messageID: message.id,
type: "reasoning",
text: "summary",
} as Part
const getParts = () => [reasoning]

const hidden = Timeline.constructMessageRows(user, getParts, [message], 0, false, "idle", false)
const shown = Timeline.constructMessageRows(user, getParts, [message], 0, true, "idle", false)

expect(hidden.some((row) => row._tag === "AssistantPart")).toBe(false)
expect(shown.some((row) => row._tag === "AssistantPart")).toBe(true)
})

test("keeps an older tool revision when a later terminal message has no renderable parts", async () => {
const { Timeline } = await import("./message-timeline.data")
const old = {
...assistant("msg_tool_old"),
activityProgress: { activityID: "activity-tool", revision: 0, state: "progress" as const },
}
const terminal = {
...assistant("msg_tool_terminal"),
activityProgress: { activityID: "activity-tool", revision: 1, state: "final" as const },
}
const tool = {
id: "prt_tool_old",
sessionID: user.sessionID,
Expand All @@ -268,10 +285,8 @@ describe("message timeline activity progress", () => {
state: { status: "completed", input: {}, output: "pending", title: "poll", time: { start: 1, end: 2 } },
} as Part
const parts = new Map([[old.id, [tool]]])
const visibility = Timeline.activityProgressVisibility([old, terminal], (id) => parts.get(id) ?? [])

const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], [old], 0, true, "idle", false, visibility)
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], [old], 0, true, "idle", false)

expect(rows.some((row) => row._tag === "AssistantPart")).toBe(false)
expect(rows.some((row) => row._tag === "AssistantPart")).toBe(true)
})
})
107 changes: 4 additions & 103 deletions packages/app/src/pages/session/message-timeline.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
activityProgressVisibility?: ReadonlySet<string>,
) {
const rows: TimelineRow.TimelineRow[] = []

Expand All @@ -159,14 +158,10 @@ export namespace Timeline {
const interrupted = interruptedMessageIndex !== -1
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error

const assistantPartRefs = latestActivityProgress(
assistantMessages,
assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
),
activityProgressVisibility,
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
Expand Down Expand Up @@ -291,100 +286,6 @@ export namespace Timeline {
return rows
}

export function activityProgressVisibility(
assistantMessages: AssistantMessage[],
getMessageParts: (messageID: string) => Part[],
) {
const refs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id).map((part) => ({ messageID: message.id, messageIndex, part })),
)
return new Set(
latestActivityProgress(assistantMessages, refs).map((ref) => `${ref.messageID}:${ref.part.id}`),
)
}

function latestActivityProgress<T extends { messageID: string; part: Part }>(
assistantMessages: AssistantMessage[],
refs: T[],
visibility?: ReadonlySet<string>,
) {
const progressByMessage = new Map<string, NonNullable<ReturnType<typeof messageActivityProgress>>>()
assistantMessages.forEach((message) => {
const marker = messageActivityProgress(message)
if (marker) progressByMessage.set(message.id, marker)
})
refs.forEach((ref) => {
const marker = partActivityProgress(ref.part)
const projected = progressByMessage.get(ref.messageID)
if (
marker &&
projected &&
(marker.activityID !== projected.activityID || marker.revision !== projected.revision)
)
console.error("Conflicting activity progress projection", {
messageID: ref.messageID,
projected,
legacy: marker,
})
if (projected) return
if (marker) progressByMessage.set(ref.messageID, marker)
})
const markerFor = (ref: T) => progressByMessage.get(ref.messageID) ?? partActivityProgress(ref.part)
if (visibility)
return refs.filter((ref) => {
if (!markerFor(ref)) return true
return visibility.has(`${ref.messageID}:${ref.part.id}`)
})
const selected = new Map<string, { revision: number; terminal: boolean }>()
progressByMessage.forEach((marker) => {
if (!marker) return
const terminal = marker.state !== "provisional" && marker.state !== "progress"
const current = selected.get(marker.activityID)
if (
current &&
((current.terminal && !terminal) || (current.terminal === terminal && current.revision > marker.revision))
)
return
selected.set(marker.activityID, { revision: marker.revision, terminal })
})
return refs.filter((ref) => {
const marker = markerFor(ref)
if (!marker) return true
const current = selected.get(marker.activityID)
return (
current?.revision === marker.revision &&
current.terminal === (marker.state !== "provisional" && marker.state !== "progress")
)
})
}

function messageActivityProgress(message: AssistantMessage) {
const marker = message.activityProgress
if (!marker) return
if (!marker.activityID || !Number.isInteger(marker.revision) || marker.revision < 0) return
if (
!["provisional", "progress", "final", "interrupted", "recovery_required", "failed"].includes(marker.state)
)
return
return marker
}

function partActivityProgress(part: Part) {
if (part.type !== "text") return
const value = part.metadata?.deepagent_activity_progress
if (!value || typeof value !== "object") return
const marker = value as Record<string, unknown>
if (typeof marker.activity_id !== "string" || marker.activity_id.length === 0) return
if (typeof marker.revision !== "number" || !Number.isInteger(marker.revision) || marker.revision < 0) return
if (!["provisional", "progress", "final", "interrupted", "recovery_required"].includes(String(marker.state)))
return
return {
activityID: marker.activity_id,
revision: marker.revision,
state: marker.state as "provisional" | "progress" | "final" | "interrupted" | "recovery_required",
}
}

function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
Expand Down
7 changes: 0 additions & 7 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -586,12 +586,6 @@ export function MessageTimeline(props: {
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
const activityProgressVisibility = createMemo(() =>
Timeline.activityProgressVisibility(
sessionMessages().filter((message): message is AssistantMessage => message.role === "assistant"),
getMsgParts,
),
)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
Expand Down Expand Up @@ -622,7 +616,6 @@ export function MessageTimeline(props: {
settings.general.showReasoningSummaries(),
sessionStatus().type,
activeMessageID() === userMessage.id,
activityProgressVisibility(),
)

return reuseTimelineRows(previous, rows)
Expand Down
12 changes: 4 additions & 8 deletions packages/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const sentry =
})
: false

export default defineConfig(({ command }) => ({
export default defineConfig({
main: {
define: {
"import.meta.env.DEEPAGENT_CODE_CHANNEL": JSON.stringify(channel),
Expand All @@ -47,19 +47,15 @@ export default defineConfig(({ command }) => ({
enforce: "pre",
resolveId(id) {
if (id !== "virtual:deepagent-code-server") return
if (command === "build") return { id: "./chunks/node.js", external: true }
return this.resolve(`${DEEPAGENT_CODE_SERVER_DIST}/node.js`)
return { id: "./chunks/node.js", external: true }
},
},
{
name: "deepagent-code:copy-server-assets",
async writeBundle() {
await mkdir("./out/main/chunks", { recursive: true })
for (const file of await readdir(DEEPAGENT_CODE_SERVER_DIST)) {
if (
!file.endsWith(".wasm") &&
(command !== "build" || !["node.js", "node.js.map", "models-dev.build.json"].includes(file))
)
if (!file.endsWith(".wasm") && !["node.js", "node.js.map", "models-dev.build.json"].includes(file))
continue
await copyFile(`${DEEPAGENT_CODE_SERVER_DIST}/${file}`, `./out/main/chunks/${file}`)
}
Expand Down Expand Up @@ -93,4 +89,4 @@ export default defineConfig(({ command }) => ({
},
},
},
}))
})
Loading