Skip to content

Commit f4b864c

Browse files
authored
v4.0.7-r2 (#112)
### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._
1 parent f442bc0 commit f4b864c

59 files changed

Lines changed: 5087 additions & 464 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/desktop-build.yml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ jobs:
121121
working-directory: packages/desktop
122122
env:
123123
DEEPAGENT_CODE_CHANNEL: ${{ github.event_name == 'push' && 'prod' || (github.event.inputs.channel || 'prod') }}
124-
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
125124
NODE_OPTIONS: --max-old-space-size=4096
126125
run: bun run build
127126

‎.github/workflows/publish.yml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ jobs:
9494
env:
9595
DEEPAGENT_CODE_VERSION: ${{ needs.version.outputs.version }}
9696
DEEPAGENT_CODE_RELEASE: ${{ needs.version.outputs.release }}
97-
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
9897
GH_REPO: ${{ needs.version.outputs.repo }}
9998
GH_TOKEN: ${{ steps.committer.outputs.token }}
10099

@@ -327,7 +326,6 @@ jobs:
327326
working-directory: packages/desktop
328327
env:
329328
DEEPAGENT_CODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
330-
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
331329
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
332330
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
333331
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}

‎nix/deepagent-code.nix‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
nodejs,
77
sysctl,
88
makeBinaryWrapper,
9-
models-dev,
109
ripgrep,
1110
installShellFiles,
1211
versionCheckHook,
@@ -28,7 +27,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
2827
nodejs # for patchShebangs node_modules
2928
installShellFiles
3029
makeBinaryWrapper
31-
models-dev
3230
writableTmpDirAsHomeHook
3331
];
3432

@@ -42,7 +40,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
4240
runHook postConfigure
4341
'';
4442

45-
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
4643
env.DEEPAGENT_CODE_DISABLE_MODELS_FETCH = true;
4744
env.DEEPAGENT_CODE_VERSION = finalAttrs.version;
4845
env.DEEPAGENT_CODE_CHANNEL = "prod";

‎packages/app/src/components/prompt-input/submit.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ describe("prompt submit worktree selection", () => {
447447
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
448448
})
449449

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

@@ -480,7 +480,7 @@ describe("prompt submit worktree selection", () => {
480480
model: { providerID: "provider", modelID: "model", variant: "high" },
481481
},
482482
})
483-
expect(optimisticRemoved).toHaveLength(1)
483+
expect(optimisticRemoved).toHaveLength(0)
484484
})
485485

486486
test("seeds new sessions before optimistic prompts are added", async () => {

‎packages/app/src/components/prompt-input/submit.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
437437
input.onPromptInput?.({ promptInput, optimisticParts: submittedParts.optimisticParts })
438438
const admission = await input.client.session.promptAsync(promptInput)
439439
if (!admission.data?.messageID) throw new Error("Prompt admission returned no durable receipt")
440-
// The server may mint a different canonical ID for a busy-session steer. The durable event is
441-
// authoritative, so remove a mismatched client-keyed placeholder once admission succeeds instead of
442-
// leaving it around to render beside the canonical server message.
443-
if (admission.data.messageID !== messageID) remove()
440+
// A chat steer is only projected into canonical history at the next provider boundary. Keep the
441+
// client-keyed placeholder visible until that correlated message.updated event replaces it.
442+
if (admission.data.messageID !== messageID && admission.data.delivery !== "steer") remove()
444443
return true
445444
} catch (err) {
446445
batch(() => {

‎packages/app/src/context/global-sync/event-reducer.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ describe("applyDirectoryEvent", () => {
430430
expect(store.part.msg_2).toBeUndefined()
431431
})
432432

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

464464
expect(store.message[sessionID]?.map((message) => message.id)).toEqual([canonical.id])
465+
expect(store.message[sessionID]).toHaveLength(1)
465466
expect(store.part[clientMessageID]).toBeUndefined()
466467
expect(store.part_text_accum_delta[clientPart.id]).toBeUndefined()
467468
})

‎packages/app/src/pages/session/message-timeline.data.test.ts‎

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { afterAll, describe, expect, mock, test } from "bun:test"
2-
import type { Part, UserMessage } from "@deepagent-code/sdk/v2/client"
2+
import type { AssistantMessage, Part, UserMessage } from "@deepagent-code/sdk/v2/client"
33

44
mock.module("@deepagent-code/ui/message-part", () => ({
5-
groupParts: () => [],
6-
renderable: () => false,
5+
groupParts: (refs: { messageID: string; part: Part }[]) =>
6+
refs.map((item) => ({
7+
key: `part:${item.messageID}:${item.part.id}`,
8+
type: "part",
9+
ref: { messageID: item.messageID, partID: item.part.id },
10+
})),
11+
renderable: () => true,
712
}))
813

914
afterAll(() => mock.restore())
@@ -39,3 +44,145 @@ describe("message timeline compaction", () => {
3944
)
4045
})
4146
})
47+
48+
describe("message timeline activity progress", () => {
49+
const user = {
50+
id: "msg_user",
51+
sessionID: "ses_1",
52+
role: "user",
53+
agent: "build",
54+
model: { providerID: "deepseek", modelID: "deepseek-chat" },
55+
time: { created: 1 },
56+
} as UserMessage
57+
const assistant = (id: string) =>
58+
({
59+
id,
60+
sessionID: user.sessionID,
61+
parentID: user.id,
62+
role: "assistant",
63+
mode: "build",
64+
agent: "build",
65+
modelID: "deepseek-chat",
66+
providerID: "deepseek",
67+
path: { cwd: "/project", root: "/project" },
68+
cost: 0,
69+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
70+
time: { created: 1, completed: 2 },
71+
finish: "tool-calls",
72+
}) as AssistantMessage
73+
const progress = (messageID: string, revision: number, state: "progress" | "final") =>
74+
({
75+
id: `prt_${state}_${revision}`,
76+
sessionID: user.sessionID,
77+
messageID,
78+
type: "text",
79+
text: `revision ${revision}`,
80+
metadata: {
81+
deepagent_activity_progress: {
82+
activity_id: "activity-1",
83+
revision,
84+
state,
85+
},
86+
},
87+
}) as Part
88+
89+
test("shows only the latest settled progress for one activity", async () => {
90+
const { Timeline } = await import("./message-timeline.data")
91+
const messages = [assistant("msg_a0"), assistant("msg_a1")]
92+
const parts = new Map([
93+
[messages[0].id, [progress(messages[0].id, 0, "progress")]],
94+
[messages[1].id, [progress(messages[1].id, 1, "progress")]],
95+
])
96+
97+
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false)
98+
expect(
99+
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
100+
).toEqual(["prt_progress_1"])
101+
})
102+
103+
test("replaces settled progress with the activity final", async () => {
104+
const { Timeline } = await import("./message-timeline.data")
105+
const messages = [assistant("msg_a0"), assistant("msg_a1"), { ...assistant("msg_a2"), finish: "stop" }]
106+
const parts = new Map([
107+
[messages[0].id, [progress(messages[0].id, 0, "progress")]],
108+
[messages[1].id, [progress(messages[1].id, 1, "progress")]],
109+
[messages[2].id, [progress(messages[2].id, 2, "final")]],
110+
])
111+
112+
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false)
113+
expect(
114+
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
115+
).toEqual(["prt_final_2"])
116+
})
117+
118+
test("collapses every text part in one activity across separate parent user rows", async () => {
119+
const { Timeline } = await import("./message-timeline.data")
120+
const user2 = { ...user, id: "msg_user_2" }
121+
const firstAssistant = assistant("msg_cross_a0")
122+
const secondAssistant = { ...assistant("msg_cross_a1"), parentID: user2.id }
123+
const plain = (messageID: string, id: string, text: string) =>
124+
({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part
125+
const messages = [firstAssistant, secondAssistant]
126+
const parts = new Map([
127+
[
128+
firstAssistant.id,
129+
[progress(firstAssistant.id, 0, "progress"), plain(firstAssistant.id, "prt_cross_old_plain", "old detail")],
130+
],
131+
[
132+
secondAssistant.id,
133+
[
134+
progress(secondAssistant.id, 1, "progress"),
135+
plain(secondAssistant.id, "prt_cross_latest_plain", "latest detail"),
136+
],
137+
],
138+
])
139+
const getParts = (id: string) => parts.get(id) ?? []
140+
const visibility = Timeline.activityProgressVisibility(messages, getParts)
141+
const firstRows = Timeline.constructMessageRows(
142+
user,
143+
getParts,
144+
[firstAssistant],
145+
0,
146+
false,
147+
"idle",
148+
false,
149+
visibility,
150+
)
151+
const secondRows = Timeline.constructMessageRows(
152+
user2,
153+
getParts,
154+
[secondAssistant],
155+
1,
156+
false,
157+
"idle",
158+
false,
159+
visibility,
160+
)
161+
expect(firstRows.some((row) => row._tag === "AssistantPart")).toBe(false)
162+
expect(
163+
secondRows.flatMap((row) =>
164+
row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [],
165+
),
166+
).toEqual(["prt_progress_1", "prt_cross_latest_plain"])
167+
})
168+
169+
test("applies one revision marker to every text part in the assistant message", async () => {
170+
const { Timeline } = await import("./message-timeline.data")
171+
const messages = [assistant("msg_multi_a0"), { ...assistant("msg_multi_a1"), finish: "stop" }]
172+
const plain = (messageID: string, id: string, text: string) =>
173+
({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part
174+
const parts = new Map([
175+
[messages[0].id, [progress(messages[0].id, 0, "progress"), plain(messages[0].id, "prt_old_plain", "old detail")]],
176+
[
177+
messages[1].id,
178+
[progress(messages[1].id, 1, "final"), plain(messages[1].id, "prt_final_plain", "final detail")],
179+
],
180+
])
181+
182+
const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false)
183+
184+
expect(
185+
rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])),
186+
).toEqual(["prt_final_1", "prt_final_plain"])
187+
})
188+
})

‎packages/app/src/pages/session/message-timeline.data.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export namespace Timeline {
139139
showReasoning: boolean,
140140
status: SessionStatus["type"],
141141
isActive: boolean,
142+
activityProgressVisibility?: ReadonlySet<string>,
142143
) {
143144
const rows: TimelineRow.TimelineRow[] = []
144145

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

153-
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
154-
getMessageParts(message.id)
155-
.filter((part) => renderable(part, showReasoning))
156-
.map((part) => ({ messageID: message.id, messageIndex, part })),
154+
const assistantPartRefs = latestActivityProgress(
155+
assistantMessages.flatMap((message, messageIndex) =>
156+
getMessageParts(message.id)
157+
.filter((part) => renderable(part, showReasoning))
158+
.map((part) => ({ messageID: message.id, messageIndex, part })),
159+
),
160+
activityProgressVisibility,
157161
)
158162
const assistantItems =
159163
interrupted && !compaction
@@ -276,6 +280,68 @@ export namespace Timeline {
276280
return rows
277281
}
278282

283+
export function activityProgressVisibility(
284+
assistantMessages: AssistantMessage[],
285+
getMessageParts: (messageID: string) => Part[],
286+
) {
287+
const refs = assistantMessages.flatMap((message, messageIndex) =>
288+
getMessageParts(message.id).map((part) => ({ messageID: message.id, messageIndex, part })),
289+
)
290+
return new Set(latestActivityProgress(refs).map((ref) => `${ref.messageID}:${ref.part.id}`))
291+
}
292+
293+
function latestActivityProgress<T extends { messageID: string; part: Part }>(
294+
refs: T[],
295+
visibility?: ReadonlySet<string>,
296+
) {
297+
const progressByMessage = new Map<string, NonNullable<ReturnType<typeof activityProgress>>>()
298+
refs.forEach((ref) => {
299+
const marker = activityProgress(ref.part)
300+
if (marker) progressByMessage.set(ref.messageID, marker)
301+
})
302+
const markerFor = (ref: T) =>
303+
activityProgress(ref.part) ?? (ref.part.type === "text" ? progressByMessage.get(ref.messageID) : undefined)
304+
if (visibility)
305+
return refs.filter((ref) => {
306+
if (!markerFor(ref)) return true
307+
return visibility.has(`${ref.messageID}:${ref.part.id}`)
308+
})
309+
const selected = new Map<string, { revision: number; terminal: boolean }>()
310+
refs.forEach((ref) => {
311+
const marker = markerFor(ref)
312+
if (!marker) return
313+
const terminal = marker.state !== "progress"
314+
const current = selected.get(marker.activityID)
315+
if (
316+
current &&
317+
((current.terminal && !terminal) || (current.terminal === terminal && current.revision > marker.revision))
318+
)
319+
return
320+
selected.set(marker.activityID, { revision: marker.revision, terminal })
321+
})
322+
return refs.filter((ref) => {
323+
const marker = markerFor(ref)
324+
if (!marker) return true
325+
const current = selected.get(marker.activityID)
326+
return current?.revision === marker.revision && current.terminal === (marker.state !== "progress")
327+
})
328+
}
329+
330+
function activityProgress(part: Part) {
331+
if (part.type !== "text") return
332+
const value = part.metadata?.deepagent_activity_progress
333+
if (!value || typeof value !== "object") return
334+
const marker = value as Record<string, unknown>
335+
if (typeof marker.activity_id !== "string" || marker.activity_id.length === 0) return
336+
if (typeof marker.revision !== "number" || !Number.isInteger(marker.revision) || marker.revision < 0) return
337+
if (!["progress", "final", "interrupted", "recovery_required"].includes(String(marker.state))) return
338+
return {
339+
activityID: marker.activity_id,
340+
revision: marker.revision,
341+
state: marker.state as "progress" | "final" | "interrupted" | "recovery_required",
342+
}
343+
}
344+
279345
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
280346
return typeof value.file === "string"
281347
}

‎packages/app/src/pages/session/message-timeline.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,9 @@ export function MessageTimeline(props: {
322322
return sync.data.message[id] ?? emptyMessages
323323
})
324324
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
325-
const sessionByID = createMemo(() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const)))
325+
const sessionByID = createMemo(
326+
() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const)),
327+
)
326328
const assistantMessagesByParent = createMemo(() => {
327329
const result = new Map<string, AssistantMessage[]>()
328330
for (const message of sessionMessages()) {
@@ -406,9 +408,7 @@ export function MessageTimeline(props: {
406408
// Fork lineage carried on the session's own metadata (set by backend fork()). Drives the
407409
// full-width "derived from ‹parent›" banner at the top of the forked transcript.
408410
const forkedFrom = createMemo(() => {
409-
const value = info()?.metadata?.forkedFrom as
410-
| { parentSessionID?: string; parentTitle?: string }
411-
| undefined
411+
const value = info()?.metadata?.forkedFrom as { parentSessionID?: string; parentTitle?: string } | undefined
412412
if (!value?.parentSessionID) return undefined
413413
return { parentSessionID: value.parentSessionID, parentTitle: value.parentTitle ?? "" }
414414
})
@@ -424,6 +424,12 @@ export function MessageTimeline(props: {
424424
})
425425
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
426426
const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
427+
const activityProgressVisibility = createMemo(() =>
428+
Timeline.activityProgressVisibility(
429+
sessionMessages().filter((message): message is AssistantMessage => message.role === "assistant"),
430+
getMsgParts,
431+
),
432+
)
427433
const childTaskDescription = createMemo(() => {
428434
const id = sessionID()
429435
if (!id) return
@@ -454,6 +460,7 @@ export function MessageTimeline(props: {
454460
settings.general.showReasoningSummaries(),
455461
sessionStatus().type,
456462
activeMessageID() === userMessage.id,
463+
activityProgressVisibility(),
457464
)
458465

459466
return reuseTimelineRows(previous, rows)

0 commit comments

Comments
 (0)