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: 0 additions & 1 deletion .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ jobs:
working-directory: packages/desktop
env:
DEEPAGENT_CODE_CHANNEL: ${{ github.event_name == 'push' && 'prod' || (github.event.inputs.channel || 'prod') }}
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
NODE_OPTIONS: --max-old-space-size=4096
run: bun run build

Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ jobs:
env:
DEEPAGENT_CODE_VERSION: ${{ needs.version.outputs.version }}
DEEPAGENT_CODE_RELEASE: ${{ needs.version.outputs.release }}
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}

Expand Down Expand Up @@ -327,7 +326,6 @@ jobs:
working-directory: packages/desktop
env:
DEEPAGENT_CODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
Expand Down
3 changes: 0 additions & 3 deletions nix/deepagent-code.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
Expand All @@ -28,7 +27,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
writableTmpDirAsHomeHook
];

Expand All @@ -42,7 +40,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postConfigure
'';

env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.DEEPAGENT_CODE_DISABLE_MODELS_FETCH = true;
env.DEEPAGENT_CODE_VERSION = finalAttrs.version;
env.DEEPAGENT_CODE_CHANNEL = "prod";
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})

test("includes the selected variant on optimistic prompts", async () => {
test("keeps an optimistic steer visible after its durable receipt", async () => {
params = { id: "session-1" }
variant = "high"

Expand Down Expand Up @@ -480,7 +480,7 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(optimisticRemoved).toHaveLength(1)
expect(optimisticRemoved).toHaveLength(0)
})

test("seeds new sessions before optimistic prompts are added", async () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,10 +437,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.onPromptInput?.({ promptInput, optimisticParts: submittedParts.optimisticParts })
const admission = await input.client.session.promptAsync(promptInput)
if (!admission.data?.messageID) throw new Error("Prompt admission returned no durable receipt")
// The server may mint a different canonical ID for a busy-session steer. The durable event is
// authoritative, so remove a mismatched client-keyed placeholder once admission succeeds instead of
// leaving it around to render beside the canonical server message.
if (admission.data.messageID !== messageID) remove()
// A chat steer is only projected into canonical history at the next provider boundary. Keep the
// client-keyed placeholder visible until that correlated message.updated event replaces it.
if (admission.data.messageID !== messageID && admission.data.delivery !== "steer") remove()
return true
} catch (err) {
batch(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ describe("applyDirectoryEvent", () => {
expect(store.part.msg_2).toBeUndefined()
})

test("reconciles a canonical steer event before its HTTP receipt", () => {
test("replaces a retained optimistic steer when its canonical event arrives", () => {
const sessionID = "ses_1"
const clientMessageID = "msg_client"
const canonical = {
Expand Down Expand Up @@ -462,6 +462,7 @@ describe("applyDirectoryEvent", () => {
})

expect(store.message[sessionID]?.map((message) => message.id)).toEqual([canonical.id])
expect(store.message[sessionID]).toHaveLength(1)
expect(store.part[clientMessageID]).toBeUndefined()
expect(store.part_text_accum_delta[clientPart.id]).toBeUndefined()
})
Expand Down
153 changes: 150 additions & 3 deletions packages/app/src/pages/session/message-timeline.data.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { afterAll, describe, expect, mock, test } from "bun:test"
import type { Part, UserMessage } from "@deepagent-code/sdk/v2/client"
import type { AssistantMessage, Part, UserMessage } from "@deepagent-code/sdk/v2/client"

mock.module("@deepagent-code/ui/message-part", () => ({
groupParts: () => [],
renderable: () => false,
groupParts: (refs: { messageID: string; part: Part }[]) =>
refs.map((item) => ({
key: `part:${item.messageID}:${item.part.id}`,
type: "part",
ref: { messageID: item.messageID, partID: item.part.id },
})),
renderable: () => true,
}))

afterAll(() => mock.restore())
Expand Down Expand Up @@ -39,3 +44,145 @@ describe("message timeline compaction", () => {
)
})
})

describe("message timeline activity progress", () => {
const user = {
id: "msg_user",
sessionID: "ses_1",
role: "user",
agent: "build",
model: { providerID: "deepseek", modelID: "deepseek-chat" },
time: { created: 1 },
} as UserMessage
const assistant = (id: string) =>
({
id,
sessionID: user.sessionID,
parentID: user.id,
role: "assistant",
mode: "build",
agent: "build",
modelID: "deepseek-chat",
providerID: "deepseek",
path: { cwd: "/project", root: "/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
finish: "tool-calls",
}) as AssistantMessage
const progress = (messageID: string, revision: number, state: "progress" | "final") =>
({
id: `prt_${state}_${revision}`,
sessionID: user.sessionID,
messageID,
type: "text",
text: `revision ${revision}`,
metadata: {
deepagent_activity_progress: {
activity_id: "activity-1",
revision,
state,
},
},
}) as Part

test("shows only the latest settled progress for one activity", async () => {
const { Timeline } = await import("./message-timeline.data")
const messages = [assistant("msg_a0"), assistant("msg_a1")]
const parts = new Map([
[messages[0].id, [progress(messages[0].id, 0, "progress")]],
[messages[1].id, [progress(messages[1].id, 1, "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"])
})

test("replaces settled progress with the activity final", 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([
[messages[0].id, [progress(messages[0].id, 0, "progress")]],
[messages[1].id, [progress(messages[1].id, 1, "progress")]],
[messages[2].id, [progress(messages[2].id, 2, "final")]],
])

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"])
})

test("collapses every text part in one activity 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")
const secondAssistant = { ...assistant("msg_cross_a1"), parentID: user2.id }
const plain = (messageID: string, id: string, text: string) =>
({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part
const messages = [firstAssistant, secondAssistant]
const parts = new Map([
[
firstAssistant.id,
[progress(firstAssistant.id, 0, "progress"), plain(firstAssistant.id, "prt_cross_old_plain", "old detail")],
],
[
secondAssistant.id,
[
progress(secondAssistant.id, 1, "progress"),
plain(secondAssistant.id, "prt_cross_latest_plain", "latest detail"),
],
],
])
const getParts = (id: string) => parts.get(id) ?? []
const visibility = Timeline.activityProgressVisibility(messages, getParts)
const firstRows = Timeline.constructMessageRows(
user,
getParts,
[firstAssistant],
0,
false,
"idle",
false,
visibility,
)
const secondRows = Timeline.constructMessageRows(
user2,
getParts,
[secondAssistant],
1,
false,
"idle",
false,
visibility,
)
expect(firstRows.some((row) => row._tag === "AssistantPart")).toBe(false)
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 () => {
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) =>
({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part
const parts = new Map([
[messages[0].id, [progress(messages[0].id, 0, "progress"), plain(messages[0].id, "prt_old_plain", "old detail")]],
[
messages[1].id,
[progress(messages[1].id, 1, "final"), plain(messages[1].id, "prt_final_plain", "final detail")],
],
])

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_1", "prt_final_plain"])
})
})
74 changes: 70 additions & 4 deletions packages/app/src/pages/session/message-timeline.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
activityProgressVisibility?: ReadonlySet<string>,
) {
const rows: TimelineRow.TimelineRow[] = []

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

const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
const assistantPartRefs = latestActivityProgress(
assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
),
activityProgressVisibility,
)
const assistantItems =
interrupted && !compaction
Expand Down Expand Up @@ -276,6 +280,68 @@ 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(refs).map((ref) => `${ref.messageID}:${ref.part.id}`))
}

function latestActivityProgress<T extends { messageID: string; part: Part }>(
refs: T[],
visibility?: ReadonlySet<string>,
) {
const progressByMessage = new Map<string, NonNullable<ReturnType<typeof activityProgress>>>()
refs.forEach((ref) => {
const marker = activityProgress(ref.part)
if (marker) progressByMessage.set(ref.messageID, marker)
})
const markerFor = (ref: T) =>
activityProgress(ref.part) ?? (ref.part.type === "text" ? progressByMessage.get(ref.messageID) : undefined)
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 }>()
refs.forEach((ref) => {
const marker = markerFor(ref)
if (!marker) return
const terminal = 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 !== "progress")
})
}

function activityProgress(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 (!["progress", "final", "interrupted", "recovery_required"].includes(String(marker.state))) return
return {
activityID: marker.activity_id,
revision: marker.revision,
state: marker.state as "progress" | "final" | "interrupted" | "recovery_required",
}
}

function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,9 @@ export function MessageTimeline(props: {
return sync.data.message[id] ?? emptyMessages
})
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
const sessionByID = createMemo(() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const)))
const sessionByID = createMemo(
() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const)),
)
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
for (const message of sessionMessages()) {
Expand Down Expand Up @@ -406,9 +408,7 @@ export function MessageTimeline(props: {
// Fork lineage carried on the session's own metadata (set by backend fork()). Drives the
// full-width "derived from ‹parent›" banner at the top of the forked transcript.
const forkedFrom = createMemo(() => {
const value = info()?.metadata?.forkedFrom as
| { parentSessionID?: string; parentTitle?: string }
| undefined
const value = info()?.metadata?.forkedFrom as { parentSessionID?: string; parentTitle?: string } | undefined
if (!value?.parentSessionID) return undefined
return { parentSessionID: value.parentSessionID, parentTitle: value.parentTitle ?? "" }
})
Expand All @@ -424,6 +424,12 @@ 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 @@ -454,6 +460,7 @@ export function MessageTimeline(props: {
settings.general.showReasoningSummaries(),
sessionStatus().type,
activeMessageID() === userMessage.id,
activityProgressVisibility(),
)

return reuseTimelineRows(previous, rows)
Expand Down
Loading
Loading