| author | |
| committer | |
| log | d8b12ae801f2123e9be73d2f4139f8994e4d1d19 |
| tree | 3c78c05698209f4b6bfdeba84ec171b292e23205 |
| parent | 31caa5652b0d3d68660c914a85af5101c057f6a0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>3 files changed, 73 insertions(+), 1 deletions(-)
apps/web/src/components/ChatView.logic.test.ts+56-1| ... | @@ -1,4 +1,11 @@ | ... | @@ -1,4 +1,11 @@ |
| 1 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | MessageId, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ThreadId, | ||
| 7 | TurnId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 10 | ||
| 4 | import type { Thread } from "../types"; | 11 | import type { Thread } from "../types"; |
| ... | @@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 359 | localDispatch, | 366 | localDispatch, |
| 360 | phase: "ready", | 367 | phase: "ready", |
| 361 | latestTurn: completedTurn, | 368 | latestTurn: completedTurn, |
| 369 | latestUserMessageId: localDispatch.latestUserMessageId, | ||
| 362 | session: readySession, | 370 | session: readySession, |
| 363 | hasPendingApproval: false, | 371 | hasPendingApproval: false, |
| 364 | hasPendingUserInput: false, | 372 | hasPendingUserInput: false, |
| ... | @@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 384 | localDispatch, | 392 | localDispatch, |
| 385 | phase: "ready", | 393 | phase: "ready", |
| 386 | latestTurn: newerTurn, | 394 | latestTurn: newerTurn, |
| 395 | latestUserMessageId: localDispatch.latestUserMessageId, | ||
| 387 | session: { ...readySession, updatedAt: newerTurn.completedAt }, | 396 | session: { ...readySession, updatedAt: newerTurn.completedAt }, |
| 388 | hasPendingApproval: false, | 397 | hasPendingApproval: false, |
| 389 | hasPendingUserInput: false, | 398 | hasPendingUserInput: false, |
| ... | @@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 410 | localDispatch, | 419 | localDispatch, |
| 411 | phase: "running", | 420 | phase: "running", |
| 412 | latestTurn: runningTurn, | 421 | latestTurn: runningTurn, |
| 422 | latestUserMessageId: localDispatch.latestUserMessageId, | ||
| 413 | session: { | 423 | session: { |
| 414 | ...readySession, | 424 | ...readySession, |
| 415 | status: "running", | 425 | status: "running", |
| ... | @@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 425 | localDispatch, | 435 | localDispatch, |
| 426 | phase: "running", | 436 | phase: "running", |
| 427 | latestTurn: runningTurn, | 437 | latestTurn: runningTurn, |
| 438 | latestUserMessageId: localDispatch.latestUserMessageId, | ||
| 428 | session: { | 439 | session: { |
| 429 | ...readySession, | 440 | ...readySession, |
| 430 | status: "running", | 441 | status: "running", |
| ... | @@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 437 | ).toBe(true); | 448 | ).toBe(true); |
| 438 | }); | 449 | }); |
| 439 | 450 | ||
| 451 | it("acknowledges a steering message projected onto the current running turn", () => { | ||
| 452 | const runningTurn = { | ||
| 453 | ...completedTurn, | ||
| 454 | state: "running" as const, | ||
| 455 | completedAt: null, | ||
| 456 | }; | ||
| 457 | const runningSession = { | ||
| 458 | ...readySession, | ||
| 459 | status: "running" as const, | ||
| 460 | activeTurnId: runningTurn.turnId, | ||
| 461 | }; | ||
| 462 | const localDispatch = createLocalDispatchSnapshot( | ||
| 463 | makeThread({ | ||
| 464 | latestTurn: runningTurn, | ||
| 465 | session: runningSession, | ||
| 466 | messages: [ | ||
| 467 | { | ||
| 468 | id: MessageId.make("message-before-steer"), | ||
| 469 | role: "user", | ||
| 470 | text: "Initial prompt", | ||
| 471 | turnId: runningTurn.turnId, | ||
| 472 | createdAt: runningTurn.requestedAt, | ||
| 473 | updatedAt: runningTurn.requestedAt, | ||
| 474 | streaming: false, | ||
| 475 | }, | ||
| 476 | ], | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | |||
| 480 | expect( | ||
| 481 | hasServerAcknowledgedLocalDispatch({ | ||
| 482 | localDispatch, | ||
| 483 | phase: "running", | ||
| 484 | latestTurn: runningTurn, | ||
| 485 | latestUserMessageId: MessageId.make("message-steer"), | ||
| 486 | session: runningSession, | ||
| 487 | hasPendingApproval: false, | ||
| 488 | hasPendingUserInput: false, | ||
| 489 | threadError: null, | ||
| 490 | }), | ||
| 491 | ).toBe(true); | ||
| 492 | }); | ||
| 493 | |||
| 440 | it("acknowledges pending user interaction and errors immediately", () => { | 494 | it("acknowledges pending user interaction and errors immediately", () => { |
| 441 | const localDispatch = createLocalDispatchSnapshot(makeThread()); | 495 | const localDispatch = createLocalDispatchSnapshot(makeThread()); |
| 442 | const common = { | 496 | const common = { |
| 443 | localDispatch, | 497 | localDispatch, |
| 444 | phase: "ready" as const, | 498 | phase: "ready" as const, |
| 445 | latestTurn: null, | 499 | latestTurn: null, |
| 500 | latestUserMessageId: localDispatch.latestUserMessageId, | ||
| 446 | session: null, | 501 | session: null, |
| 447 | hasPendingApproval: false, | 502 | hasPendingApproval: false, |
| 448 | hasPendingUserInput: false, | 503 | hasPendingUserInput: false, |
apps/web/src/components/ChatView.logic.ts+13| ... | @@ -387,6 +387,7 @@ export async function waitForStartedServerThread( | ... | @@ -387,6 +387,7 @@ export async function waitForStartedServerThread( |
| 387 | export interface LocalDispatchSnapshot { | 387 | export interface LocalDispatchSnapshot { |
| 388 | startedAt: string; | 388 | startedAt: string; |
| 389 | preparingWorktree: boolean; | 389 | preparingWorktree: boolean; |
| 390 | latestUserMessageId: ChatMessage["id"] | null; | ||
| 390 | latestTurnTurnId: TurnId | null; | 391 | latestTurnTurnId: TurnId | null; |
| 391 | latestTurnRequestedAt: string | null; | 392 | latestTurnRequestedAt: string | null; |
| 392 | latestTurnStartedAt: string | null; | 393 | latestTurnStartedAt: string | null; |
| ... | @@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot( | ... | @@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot( |
| 401 | ): LocalDispatchSnapshot { | 402 | ): LocalDispatchSnapshot { |
| 402 | const latestTurn = activeThread?.latestTurn ?? null; | 403 | const latestTurn = activeThread?.latestTurn ?? null; |
| 403 | const session = activeThread?.session ?? null; | 404 | const session = activeThread?.session ?? null; |
| 405 | const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); | ||
| 404 | return { | 406 | return { |
| 405 | startedAt: new Date().toISOString(), | 407 | startedAt: new Date().toISOString(), |
| 406 | preparingWorktree: Boolean(options?.preparingWorktree), | 408 | preparingWorktree: Boolean(options?.preparingWorktree), |
| 409 | latestUserMessageId: latestUserMessage?.id ?? null, | ||
| 407 | latestTurnTurnId: latestTurn?.turnId ?? null, | 410 | latestTurnTurnId: latestTurn?.turnId ?? null, |
| 408 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, | 411 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, |
| 409 | latestTurnStartedAt: latestTurn?.startedAt ?? null, | 412 | latestTurnStartedAt: latestTurn?.startedAt ?? null, |
| ... | @@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 417 | localDispatch: LocalDispatchSnapshot | null; | 420 | localDispatch: LocalDispatchSnapshot | null; |
| 418 | phase: SessionPhase; | 421 | phase: SessionPhase; |
| 419 | latestTurn: Thread["latestTurn"] | null; | 422 | latestTurn: Thread["latestTurn"] | null; |
| 423 | latestUserMessageId: ChatMessage["id"] | null; | ||
| 420 | session: Thread["session"] | null; | 424 | session: Thread["session"] | null; |
| 421 | hasPendingApproval: boolean; | 425 | hasPendingApproval: boolean; |
| 422 | hasPendingUserInput: boolean; | 426 | hasPendingUserInput: boolean; |
| ... | @@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 431 | 435 | ||
| 432 | const latestTurn = input.latestTurn ?? null; | 436 | const latestTurn = input.latestTurn ?? null; |
| 433 | const session = input.session ?? null; | 437 | const session = input.session ?? null; |
| 438 | const latestUserMessageChanged = | ||
| 439 | input.localDispatch.latestUserMessageId !== input.latestUserMessageId; | ||
| 434 | const latestTurnChanged = | 440 | const latestTurnChanged = |
| 435 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || | 441 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || |
| 436 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || | 442 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || |
| ... | @@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 438 | input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); | 444 | input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); |
| 439 | 445 | ||
| 440 | if (input.phase === "running") { | 446 | if (input.phase === "running") { |
| 447 | // Steering adds a user message to the current running turn without | ||
| 448 | // necessarily changing any of the turn timestamps. Treat that projected | ||
| 449 | // message as the server acknowledgment so the composer does not remain | ||
| 450 | // stuck in its local "Sending" state until the turn settles. | ||
| 451 | if (latestUserMessageChanged) { | ||
| 452 | return true; | ||
| 453 | } | ||
| 441 | if (!latestTurnChanged) { | 454 | if (!latestTurnChanged) { |
| 442 | return false; | 455 | return false; |
| 443 | } | 456 | } |
apps/web/src/components/ChatView.tsx+4| ... | @@ -452,6 +452,8 @@ function useLocalDispatchState(input: { | ... | @@ -452,6 +452,8 @@ function useLocalDispatchState(input: { |
| 452 | threadError: string | null | undefined; | 452 | threadError: string | null | undefined; |
| 453 | }) { | 453 | }) { |
| 454 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); | 454 | const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null); |
| 455 | const latestUserMessageId = | ||
| 456 | input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; | ||
| 455 | 457 | ||
| 456 | const resetLocalDispatch = useCallback(() => { | 458 | const resetLocalDispatch = useCallback(() => { |
| 457 | setLocalDispatch(null); | 459 | setLocalDispatch(null); |
| ... | @@ -463,6 +465,7 @@ function useLocalDispatchState(input: { | ... | @@ -463,6 +465,7 @@ function useLocalDispatchState(input: { |
| 463 | localDispatch, | 465 | localDispatch, |
| 464 | phase: input.phase, | 466 | phase: input.phase, |
| 465 | latestTurn: input.activeLatestTurn, | 467 | latestTurn: input.activeLatestTurn, |
| 468 | latestUserMessageId, | ||
| 466 | session: input.activeThread?.session ?? null, | 469 | session: input.activeThread?.session ?? null, |
| 467 | hasPendingApproval: input.activePendingApproval !== null, | 470 | hasPendingApproval: input.activePendingApproval !== null, |
| 468 | hasPendingUserInput: input.activePendingUserInput !== null, | 471 | hasPendingUserInput: input.activePendingUserInput !== null, |
| ... | @@ -475,6 +478,7 @@ function useLocalDispatchState(input: { | ... | @@ -475,6 +478,7 @@ function useLocalDispatchState(input: { |
| 475 | input.activeThread?.session, | 478 | input.activeThread?.session, |
| 476 | input.phase, | 479 | input.phase, |
| 477 | input.threadError, | 480 | input.threadError, |
| 481 | latestUserMessageId, | ||
| 478 | localDispatch, | 482 | localDispatch, |
| 479 | ], | 483 | ], |
| 480 | ); | 484 | ); |