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
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
12 changes: 8 additions & 4 deletions apps/app/src/components/chat/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -346,13 +346,13 @@ export const useSessionActivityStore = create<SessionActivityStore>((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 });
}
Expand Down
13 changes: 10 additions & 3 deletions apps/app/src/react-app/domains/session/sync/session-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}

Expand Down
36 changes: 36 additions & 0 deletions apps/app/tests/session-sync-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/orchestrator/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
53 changes: 53 additions & 0 deletions evals/flows/conversation-elapsed-time.flow.mjs
Original file line number Diff line number Diff line change
@@ -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?.()");
}
},
}],
};
13 changes: 8 additions & 5 deletions evals/flows/conversation-streaming-result.flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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?"));
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading