diff --git a/apps/app/package.json b/apps/app/package.json index aa43da417b..9ca0e10503 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.50.35", + "version": "0.50.36", "type": "module", "scripts": { "test": "bun test --isolate tests/", diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx index 6249a037a4..774a1def9a 100644 --- a/apps/app/src/components/chat/message-list.tsx +++ b/apps/app/src/components/chat/message-list.tsx @@ -1459,15 +1459,19 @@ function MessageGroup({ currentTurn ? runStartedAt : null, precedingUser ? getMessageCreated(precedingUser) : null, ) - const processCompletedAt = storedTiming?.endedAt - ?? (currentTurn ? runEndedAt : null) - ?? getMessageCompleted(lastRealItem?.message ?? lastItem.message) + const hasSessionError = items.some((item) => isSessionErrorMessage(item.message)) + const transcriptCompletedAt = getMessageCompleted(lastRealItem?.message ?? lastItem.message) + const recordedEnd = storedTiming?.endedAt ?? (currentTurn ? runEndedAt : null) + const interrupted = hasSessionError || runIncomplete || (currentTurn && (runOutcome === "failed" || runOutcome === "stopped")) + // A recovered session can be observed long after its final reply was written. + const processCompletedAt = interrupted + ? recordedEnd ?? transcriptCompletedAt + : transcriptCompletedAt ?? recordedEnd const processDurationMs = processStartedAt !== null && processCompletedAt !== null && processCompletedAt >= processStartedAt ? processCompletedAt - processStartedAt : null - const hasSessionError = items.some((item) => isSessionErrorMessage(item.message)) const sessionErrorItem = items.find((item) => isSessionErrorMessage(item.message)) const inlineError = terminalError ?? (sessionErrorItem ? getMessagesText([sessionErrorItem.message]) || t("message.session_failed") : null) const renderableItems = getRenderableMessages(bodyRenderData.map(data => data.item)) diff --git a/apps/app/src/react-app/domains/session/status/session-activity-store.ts b/apps/app/src/react-app/domains/session/status/session-activity-store.ts index d86d926870..daa9426920 100644 --- a/apps/app/src/react-app/domains/session/status/session-activity-store.ts +++ b/apps/app/src/react-app/domains/session/status/session-activity-store.ts @@ -89,7 +89,7 @@ type SessionActivityStore = { seedSessionRun: (workspaceId: string, sessionId: string, status: unknown, assistantOutput: boolean) => void; setRunStatus: (workspaceId: string, sessionId: string, status: unknown) => void; setHostDelivery: (workspaceId: string, sessionId: string, active: boolean, error?: string) => void; - finishRun: (workspaceId: string, sessionId: string, outcome: "completed" | "stopped", turnId?: string) => void; + finishRun: (workspaceId: string, sessionId: string, outcome: "completed" | "stopped", turnId?: string, completedAt?: number) => void; markMessageRole: (workspaceId: string, sessionId: string, messageId: string, role: SessionMessageRole) => void; markAssistantOutput: (workspaceId: string, sessionId: string, messageId?: string, options?: { allowUnknownMessageRole?: boolean }) => void; setWaitingRequest: (workspaceId: string, sessionId: string, kind: "permission" | "question", requestId: string, waiting: boolean) => void; @@ -346,13 +346,13 @@ export const useSessionActivityStore = create((set, get) = ...(active ? {} : { runActive: false, assistantOutput: false }), }))); }, - finishRun: (workspaceId, sessionId, outcome, turnId) => { + finishRun: (workspaceId, sessionId, outcome, turnId, completedAt) => { const workspace = workspaceId.trim(); const session = sessionId.trim(); if (!workspace || !session) return; const previous = get().recordsByWorkspaceId[workspace]?.[session]; - const endedAt = previous && previous.runOutcome !== "running" && previous.runEndedAt !== null - ? previous.runEndedAt : Date.now(); + const endedAt = completedAt ?? (previous && previous.runOutcome !== "running" && previous.runEndedAt !== null + ? previous.runEndedAt : Date.now()); if (previous?.runStartedAt !== null && previous?.runStartedAt !== undefined && endedAt >= previous.runStartedAt) { saveRunTiming(workspace, session, turnId, { startedAt: previous.runStartedAt, endedAt }); } diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 8f3801524b..0f9f9970cc 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -1723,17 +1723,24 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap }), )); } else if (safeSnapshot.status.type === "idle" && !preserveRunState && snapshotAcknowledgesRun) { - const latestAssistant = incoming.findLast((message) => message.role === "assistant"); + const latestUserIndex = incoming.findLastIndex((message) => message.role === "user"); + const latestAssistant = incoming.findLast((message, index) => index > latestUserIndex && message.role === "assistant"); const metadata = latestAssistant?.metadata; const ipollowork = metadata && typeof metadata === "object" && "ipollowork" in metadata ? metadata.ipollowork : null; const finalText = latestAssistant?.parts.some((part) => part.type === "text" && part.text.trim().length > 0) && latestAssistant.parts.every((part) => part.type !== "dynamic-tool" && !part.type.startsWith("tool-")); if (ipollowork && typeof ipollowork === "object" && "completed" in ipollowork - && typeof ipollowork.completed === "number" + && typeof ipollowork.completed === "number" && Number.isFinite(ipollowork.completed) && !("codexPhase" in ipollowork && ipollowork.codexPhase === "commentary") && finalText) { - useSessionActivityStore.getState().finishRun(workspaceId, safeSnapshot.session.id, "completed", latestUserMessage(incoming)?.id); + useSessionActivityStore.getState().finishRun( + workspaceId, + safeSnapshot.session.id, + "completed", + incoming[latestUserIndex]?.id, + ipollowork.completed, + ); } } diff --git a/apps/app/tests/session-sync-permissions.test.ts b/apps/app/tests/session-sync-permissions.test.ts index ba0bb741da..17b7d4ff66 100644 --- a/apps/app/tests/session-sync-permissions.test.ts +++ b/apps/app/tests/session-sync-permissions.test.ts @@ -558,6 +558,42 @@ describe("session transcript sync", () => { } }); + test("restores a completed turn with its original end time when the snapshot arrives later", () => { + const startedAt = Date.UTC(2026, 10, 10, 10, 18, 47); + const completedAt = startedAt + 3_455_000; + const snapshot = snapshotWithMessages([ + { id: "original-prompt", role: "user", text: "Review the project", created: startedAt }, + ]); + snapshot.messages.push({ + ...uiMessage("completed-reply", "assistant", "Review complete"), + metadata: { ipollowork: { completed: completedAt, parentUserMessageId: "original-prompt" } }, + }); + + seedSessionState("workspace-a", snapshot); + seedSessionState("workspace-a", snapshot); + + const activity = useSessionActivityStore.getState(); + expect(activity.getRunOutcome("workspace-a", "session-a")).toBe("completed"); + expect(activity.recordsByWorkspaceId["workspace-a"]?.["session-a"]?.runEndedAt).toBe(completedAt); + }); + + test("does not assign the previous reply's completion time to an unanswered prompt", () => { + const snapshot = snapshotWithMessages([ + { id: "first-prompt", role: "user", text: "First question", created: 1_000 }, + ]); + snapshot.messages.push( + { + ...uiMessage("first-reply", "assistant", "First answer"), + metadata: { ipollowork: { completed: 2_000, parentUserMessageId: "first-prompt" } }, + }, + uiMessage("second-prompt", "user", "Second question", { created: 3_000 }), + ); + + seedSessionState("workspace-a", snapshot); + + expect(useSessionActivityStore.getState().getRunOutcome("workspace-a", "session-a")).toBeNull(); + }); + test("repairs a premature no-output error when the live response arrives after the idle snapshot", () => { const cleanup = __createWorkspaceSessionSyncForTest(syncInput); const releaseSession = trackWorkspaceSessionSync(syncInput, "session-a"); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0039f63c25..dad79e9d80 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@ipollowork/desktop", "desktopName": "@ipolloworkdesktop.desktop", "private": true, - "version": "0.50.35", + "version": "0.50.36", "description": "iPolloWork desktop shell", "author": { "name": "iPolloWork", diff --git a/apps/orchestrator/package.json b/apps/orchestrator/package.json index c116d8a1cb..dc084819be 100644 --- a/apps/orchestrator/package.json +++ b/apps/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-orchestrator", - "version": "0.50.35", + "version": "0.50.36", "description": "iPolloWork host orchestrator for opencode + iPolloWork server", "private": true, "type": "module", @@ -46,7 +46,7 @@ "@opencode-ai/sdk": "1.18.34", "@opentui/core": "0.1.77", "@opentui/solid": "0.1.77", - "ipollowork-server": "0.50.35", + "ipollowork-server": "0.50.36", "solid-js": "1.9.9" }, "devDependencies": { diff --git a/apps/server/package.json b/apps/server/package.json index cac883b564..c2c06e47a1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "ipollowork-server", - "version": "0.50.35", + "version": "0.50.36", "description": "Filesystem-backed API for iPolloWork remote clients", "type": "module", "bin": { diff --git a/evals/flows/conversation-elapsed-time.flow.mjs b/evals/flows/conversation-elapsed-time.flow.mjs new file mode 100644 index 0000000000..44977565d5 --- /dev/null +++ b/evals/flows/conversation-elapsed-time.flow.mjs @@ -0,0 +1,53 @@ +import { mountFixture } from "./conversation-streaming-result.flow.mjs"; + +const startedAt = Date.UTC(2026, 10, 10, 10, 18, 47); +const completedAt = startedAt + 3_455_000; +const reopenedAt = completedAt + 21 * 3_600_000 + 40 * 60_000; +const messages = [ + { id: "elapsed-user", role: "user", metadata: { ipollowork: { created: startedAt } }, parts: [{ type: "text", text: "请检查项目资料。" }] }, + { id: "elapsed-reply", role: "assistant", metadata: { ipollowork: { completed: completedAt } }, parts: [{ type: "text", text: "项目资料已核对完成。", state: "done" }] }, +]; +const state = "document.querySelector('#streaming-answer-proof [data-testid=assistant-current-state]')"; + +async function show(ctx, messageListProps) { + await ctx.eval(`(${mountFixture.toString()})(${JSON.stringify({ messageListProps })})`, { awaitPromise: true }); +} + +export default { + id: "conversation-elapsed-time", + title: "A reopened conversation shows how long its task ran", + kind: "user-facing", + steps: [{ + name: "Read the elapsed time after reopening", + run: async (ctx) => { + try { + await ctx.waitFor("Boolean(window.__ipolloworkControl)"); + await show(ctx, { messages, status: "ready", runOutcome: "stopped", runEndedAt: completedAt + 20_000 }); + await ctx.waitFor(`${state}?.textContent.includes('已停止') && ${state}?.textContent.includes('57分钟 55秒')`); + + await show(ctx, { + messages: [{ ...messages[0], metadata: { ipollowork: { created: Date.now() - 5_000 } } }], + status: "streaming", runOutcome: "running", + }); + await ctx.waitFor(`${state}?.textContent.includes('已用时')`); + const initial = await ctx.eval(`${state}.textContent`); + await ctx.waitFor(`${state}?.textContent.includes('已用时') && ${state}.textContent !== ${JSON.stringify(initial)}`); + + await ctx.prove("An overnight reopen keeps the original task duration", { + voiceover: "第二天重新打开这条会话,已完成任务仍显示用时 57 分钟 35 秒。", + action: () => show(ctx, { + messages, status: "ready", runOutcome: "completed", runEndedAt: reopenedAt, + runTimings: { "elapsed-user": { startedAt, endedAt: reopenedAt } }, + }), + assert: async () => { + await ctx.waitFor(`${state}?.textContent.includes('已完成 · 用时 57分钟 35秒')`); + ctx.assert(await ctx.eval(`${state}.closest('button').dataset.activeStatus === 'false'`), "The completed task no longer ticks"); + }, + screenshot: { name: "reopened-duration", requireText: ["用时 57分钟 35秒"], rejectText: ["22小时"] }, + }); + } finally { + await ctx.eval("window.__streamingAnswerProof?.cleanup?.()"); + } + }, + }], +}; diff --git a/evals/flows/conversation-streaming-result.flow.mjs b/evals/flows/conversation-streaming-result.flow.mjs index 3f4d0ced60..e863ecb0ed 100644 --- a/evals/flows/conversation-streaming-result.flow.mjs +++ b/evals/flows/conversation-streaming-result.flow.mjs @@ -17,10 +17,13 @@ async function assertFailureUX(ctx, title, hint, raw) { export async function mountFixture(options = {}) { window.__streamingAnswerProof?.cleanup?.(); document.querySelectorAll("#streaming-answer-proof").forEach((node) => node.remove()); - const moduleUrl = (name) => performance.getEntriesByType("resource").map(entry => entry.name).find(url => url.includes(name)); - for (let attempt = 0; attempt < 100 && !["/react.js?", "/react-dom_client.js?", "/@tanstack_react-query.js?"].every(moduleUrl); attempt++) { - await new Promise(resolve => setTimeout(resolve, 50)); - } + const entrySource = await fetch("/src/index.react.tsx").then(response => response.text()); + const moduleUrls = Array.from(entrySource.matchAll(/"([^"]+\.js\?v=[^"]+)"/g), match => match[1]); + const moduleUrl = (name) => { + const url = moduleUrls.find(url => url.includes(name)); + if (!url) throw new Error(`Could not resolve ${name} from the app entrypoint`); + return url; + }; const { default: React } = await import(moduleUrl("/react.js?")); const { default: ReactDOM } = await import(moduleUrl("/react-dom_client.js?")); const { QueryClient, QueryClientProvider } = await import(moduleUrl("/@tanstack_react-query.js?")); @@ -402,7 +405,7 @@ export async function mountFixture(options = {}) { dispatchAction() {}, setPrompt() {}, }, React.createElement("main", { ref: contentRef, className: "mx-auto max-w-[850px]" }, React.createElement("h1", { className: "mb-8 text-xl font-semibold" }, "对话流式输出"), - React.createElement(MessageList, { messages, status, runOutcome, activeMessageBaseline, runEndedAt, finalizing, retryStatus, artifactFiles, stoppedImageMessageIds, stopAcknowledged }), + React.createElement(MessageList, { messages, status, runOutcome, activeMessageBaseline, runEndedAt, finalizing, retryStatus, artifactFiles, stoppedImageMessageIds, stopAcknowledged, ...options.messageListProps }), queued.length ? React.createElement("div", { className: "mt-8" }, React.createElement(QueuedMessagesPanel, { messages: queued, onRemove: () => setQueued([]) })) : null), React.createElement(SessionScrollOverlay, { sessionId: "proof", isStreaming: status === "streaming", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcc2395075..9f962df570 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,7 +319,7 @@ importers: specifier: 0.1.77 version: 0.1.77(solid-js@1.9.9)(stage-js@1.0.0-alpha.17)(typescript@5.9.3)(web-tree-sitter@0.25.10) ipollowork-server: - specifier: 0.50.35 + specifier: 0.50.36 version: link:../server solid-js: specifier: 1.9.9