Skip to content

Commit f280330

Browse files
authored
Merge pull request #113 from deepagent-ltd/dev
v4.0.7-r2 (#112)
2 parents 67125d5 + f4b864c commit f280330

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)