feat(chat): 생각 과정(reasoning·도구 스텝) 토글 + 작성 중 스피너·경과 초 — ABAP_OPENCODE 와 같은 모양. 백엔드가 step 이벤트로 흘림. 0.1.2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5ee599fb6b
commit
5277481513
@@ -1,4 +1,4 @@
|
|||||||
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
|
import { streamLLM, type AgentStep, type LLMUsagePayload } from "@/lib/streaming"
|
||||||
import { apiPost } from "@/lib/api/client"
|
import { apiPost } from "@/lib/api/client"
|
||||||
import type { SnapStreamRequest } from "../contract/types"
|
import type { SnapStreamRequest } from "../contract/types"
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ export interface SnapStreamHandlers {
|
|||||||
onDone: () => void
|
onDone: () => void
|
||||||
onTitle?: (title: string) => void
|
onTitle?: (title: string) => void
|
||||||
onUsage?: (usage: LLMUsagePayload) => void
|
onUsage?: (usage: LLMUsagePayload) => void
|
||||||
|
onStep?: (step: AgentStep) => void
|
||||||
onError?: (e: Error) => void
|
onError?: (e: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function snapStream(
|
|||||||
onDone: () => handlers.onDone(),
|
onDone: () => handlers.onDone(),
|
||||||
onTitle: handlers.onTitle,
|
onTitle: handlers.onTitle,
|
||||||
onUsage: handlers.onUsage,
|
onUsage: handlers.onUsage,
|
||||||
|
onStep: handlers.onStep,
|
||||||
onError: handlers.onError,
|
onError: handlers.onError,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { ChevronRight, Loader2, Wrench } from "lucide-react"
|
||||||
|
import type { AgentStep } from "@/lib/streaming"
|
||||||
|
|
||||||
|
/** 생각/도구 과정 토글 — ABAP_OPENCODE 의 ThinkingBox 를 그대로 옮김.
|
||||||
|
* 진행 중엔 펼쳐진 채 흐르고, 답변 본문이 나오기 시작하거나 끝나면 자동으로 접힘. */
|
||||||
|
export function ThinkingSteps({
|
||||||
|
steps,
|
||||||
|
isDone,
|
||||||
|
answerStarted,
|
||||||
|
}: {
|
||||||
|
steps: AgentStep[]
|
||||||
|
isDone: boolean
|
||||||
|
answerStarted: boolean
|
||||||
|
}) {
|
||||||
|
const collapsed = isDone || answerStarted
|
||||||
|
const [open, setOpen] = useState(!collapsed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (collapsed) setOpen(false)
|
||||||
|
}, [collapsed])
|
||||||
|
if (steps.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 self-start text-[12.5px] font-semibold"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={13}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={`transition-transform ${open ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
생각 과정
|
||||||
|
<span className="text-[11.5px] font-normal">{steps.length} steps</span>
|
||||||
|
{!isDone && <Loader2 size={11} className="animate-spin" />}
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
steps.map((st, i) => (
|
||||||
|
<div
|
||||||
|
key={st.id}
|
||||||
|
className="border-border ml-1.5 flex items-start gap-2.5 border-l-2 py-0.5 pl-3"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground mt-px shrink-0 text-[11px] font-semibold whitespace-nowrap">
|
||||||
|
Step {i + 1}
|
||||||
|
</span>
|
||||||
|
{st.kind === "tool" ? (
|
||||||
|
<span className="bg-muted border-border text-foreground inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-semibold">
|
||||||
|
{st.status === "running" || st.status === "pending" ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wrench size={12} strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
{st.tool || "tool"}
|
||||||
|
{st.title ? ` — ${st.title}` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-[12.5px] leading-[1.55] break-words whitespace-pre-wrap">
|
||||||
|
{st.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하단 "작성 중… · N초" — 스피너 + 경과 초. busy 가 true 인 동안만 그림. */
|
||||||
|
export function WorkingRow({ busy }: { busy: boolean }) {
|
||||||
|
const [elapsed, setElapsed] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!busy) return
|
||||||
|
setElapsed(0)
|
||||||
|
const start = Date.now()
|
||||||
|
const t = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [busy])
|
||||||
|
if (!busy) return null
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-[12.5px]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
작성 중… · {elapsed}초
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export function useSnapChat(sessionId: string) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
|
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
|
||||||
|
onStep: (st) => useSnapChatStore.getState().applyStep(st),
|
||||||
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
|
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
|
||||||
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
|
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function NewChatPage() {
|
|||||||
) : (
|
) : (
|
||||||
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
|
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
|
||||||
<div className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
<div className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/aria-role -- Message 의 role 은 ARIA 아니고 화자 */}
|
||||||
<Message role="user" content={pending} />
|
<Message role="user" content={pending} />
|
||||||
<ThinkingBubble />
|
<ThinkingBubble />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ChatHeader } from "../components/ChatHeader"
|
|||||||
import { NavRail } from "../components/NavRail"
|
import { NavRail } from "../components/NavRail"
|
||||||
import { Message } from "../components/Message"
|
import { Message } from "../components/Message"
|
||||||
import { ThinkingBubble } from "../components/ThinkingBubble"
|
import { ThinkingBubble } from "../components/ThinkingBubble"
|
||||||
|
import { ThinkingSteps, WorkingRow } from "../components/ThinkingSteps"
|
||||||
import { Composer } from "../components/Composer"
|
import { Composer } from "../components/Composer"
|
||||||
|
|
||||||
export default function SessionChatPage() {
|
export default function SessionChatPage() {
|
||||||
@@ -164,12 +165,20 @@ export default function SessionChatPage() {
|
|||||||
const isLiveLast =
|
const isLiveLast =
|
||||||
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
|
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
|
||||||
if (isLiveLast) {
|
if (isLiveLast) {
|
||||||
|
const steps = m.steps ?? []
|
||||||
return (
|
return (
|
||||||
<div key={m.id}>
|
<div key={m.id} className="flex flex-col gap-2">
|
||||||
<ThinkingBubble>
|
<ThinkingBubble>
|
||||||
|
<ThinkingSteps
|
||||||
|
steps={steps}
|
||||||
|
isDone={!isStreaming}
|
||||||
|
answerStarted={m.content !== ""}
|
||||||
|
/>
|
||||||
{m.content === "" ? (
|
{m.content === "" ? (
|
||||||
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
|
// 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호
|
||||||
<span className="shimmer-text text-sm">생각하는 중…</span>
|
steps.length === 0 && (
|
||||||
|
<span className="shimmer-text text-sm">생각하는 중…</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<StreamingText
|
<StreamingText
|
||||||
text={m.content}
|
text={m.content}
|
||||||
@@ -179,6 +188,7 @@ export default function SessionChatPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ThinkingBubble>
|
</ThinkingBubble>
|
||||||
|
<WorkingRow busy={isStreaming} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,16 +206,25 @@ export default function SessionChatPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Message
|
<div key={m.id} className="flex flex-col gap-2">
|
||||||
key={m.id}
|
{m.steps && m.steps.length > 0 && (
|
||||||
role={m.role}
|
<ThinkingSteps steps={m.steps} isDone answerStarted />
|
||||||
content={m.content}
|
)}
|
||||||
totalTokens={m.totalTokens}
|
<Message
|
||||||
elapsedMs={m.elapsedMs}
|
role={m.role}
|
||||||
/>
|
content={m.content}
|
||||||
|
totalTokens={m.totalTokens}
|
||||||
|
elapsedMs={m.elapsedMs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{generatingRemotely && <ThinkingBubble />}
|
{generatingRemotely && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ThinkingBubble />
|
||||||
|
<WorkingRow busy />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Composer
|
<Composer
|
||||||
|
|||||||
@@ -151,3 +151,23 @@ describe("snapChatStore", () => {
|
|||||||
expect(useSnapChatStore.getState().isRevealing).toBe(false)
|
expect(useSnapChatStore.getState().isRevealing).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("applyStep", () => {
|
||||||
|
it("reasoning 은 같은 id 로 이어붙고 tool 은 상태 덮어씀, assistant 꼬리에만", () => {
|
||||||
|
const st = useSnapChatStore.getState()
|
||||||
|
st.reset()
|
||||||
|
st.addUserMessage("q")
|
||||||
|
st.applyStep({ id: "r1", kind: "reasoning", text: "x" }) // user 꼬리 → 무시
|
||||||
|
st.startAssistantMessage()
|
||||||
|
st.applyStep({ id: "r1", kind: "reasoning", text: "생각" })
|
||||||
|
st.applyStep({ id: "r1", kind: "reasoning", text: "중" })
|
||||||
|
st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "running" })
|
||||||
|
st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" })
|
||||||
|
const last = useSnapChatStore.getState().messages.at(-1)!
|
||||||
|
expect(last.steps).toEqual([
|
||||||
|
{ id: "r1", kind: "reasoning", text: "생각중" },
|
||||||
|
{ id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" },
|
||||||
|
])
|
||||||
|
expect(useSnapChatStore.getState().messages[0].steps).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import { randomId } from "@/lib/utils/randomId"
|
import { randomId } from "@/lib/utils/randomId"
|
||||||
import type { SnapMessage, SnapRole } from "../contract/types"
|
import type { SnapMessage, SnapRole } from "../contract/types"
|
||||||
|
import type { AgentStep } from "@/lib/streaming"
|
||||||
|
|
||||||
export interface SnapChatMessage {
|
export interface SnapChatMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,6 +15,8 @@ export interface SnapChatMessage {
|
|||||||
totalTokens?: number
|
totalTokens?: number
|
||||||
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
|
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
|
||||||
elapsedMs?: number
|
elapsedMs?: number
|
||||||
|
/** 답변 전 과정(생각·도구). 라이브 턴에만 쌓임 — DB 엔 안 남아 재진입 땐 없음. */
|
||||||
|
steps?: AgentStep[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
|
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
|
||||||
@@ -45,6 +48,8 @@ interface SnapChatState {
|
|||||||
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
|
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
|
||||||
appendRecoveredAssistant: (m: SnapMessage) => void
|
appendRecoveredAssistant: (m: SnapMessage) => void
|
||||||
appendChunk: (chunk: string) => void
|
appendChunk: (chunk: string) => void
|
||||||
|
/** step 이벤트 — 같은 id 면 reasoning 은 text 이어붙이고 tool 은 상태 덮어씀. */
|
||||||
|
applyStep: (step: AgentStep) => void
|
||||||
setStreaming: (v: boolean) => void
|
setStreaming: (v: boolean) => void
|
||||||
setRevealing: (v: boolean) => void
|
setRevealing: (v: boolean) => void
|
||||||
setController: (c: AbortController | null) => void
|
setController: (c: AbortController | null) => void
|
||||||
@@ -123,6 +128,21 @@ export const useSnapChatStore = create<SnapChatState>((set, get) => ({
|
|||||||
return { messages: next }
|
return { messages: next }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
applyStep: (step) => {
|
||||||
|
const last = get().messages.at(-1)
|
||||||
|
if (!last || last.role !== "assistant") return
|
||||||
|
const steps = [...(last.steps ?? [])]
|
||||||
|
const i = steps.findIndex((x) => x.id === step.id)
|
||||||
|
if (i < 0) steps.push(step)
|
||||||
|
else if (step.kind === "reasoning")
|
||||||
|
steps[i] = { ...steps[i], text: (steps[i].text ?? "") + (step.text ?? "") }
|
||||||
|
else steps[i] = { ...steps[i], ...step }
|
||||||
|
set((s) => {
|
||||||
|
const next = [...s.messages]
|
||||||
|
next[next.length - 1] = { ...last, steps }
|
||||||
|
return { messages: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
setStreaming: (v) => set({ isStreaming: v }),
|
setStreaming: (v) => set({ isStreaming: v }),
|
||||||
setRevealing: (v) => set({ isRevealing: v }),
|
setRevealing: (v) => set({ isRevealing: v }),
|
||||||
setController: (c) => set({ currentController: c }),
|
setController: (c) => set({ currentController: c }),
|
||||||
|
|||||||
@@ -40,10 +40,14 @@ export interface ClarifyCandidate {
|
|||||||
reason: string
|
reason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `step` 이벤트 — 에이전트 그래프 진행 단계 (agent.iter 데모용). */
|
/** `step` 이벤트 — 답변 전 과정 한 조각. reasoning 은 text 가 delta 로 누적, tool 은 같은 id 로 상태 갱신. */
|
||||||
export interface AgentStep {
|
export interface AgentStep {
|
||||||
phase: string
|
id: string
|
||||||
detail: string
|
kind: "reasoning" | "tool"
|
||||||
|
text?: string
|
||||||
|
tool?: string
|
||||||
|
status?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
|
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
|
||||||
@@ -63,7 +67,7 @@ export interface LLMStreamHandlers<TResult = unknown> {
|
|||||||
onResult?: (data: TResult) => void
|
onResult?: (data: TResult) => void
|
||||||
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
|
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
|
||||||
onClarify?: (candidates: ClarifyCandidate[]) => void
|
onClarify?: (candidates: ClarifyCandidate[]) => void
|
||||||
/** `step` 이벤트 — 에이전트 진행 단계 (agent.iter 데모용, 선택) */
|
/** `step` 이벤트 — 생각/도구 과정 조각 (선택) */
|
||||||
onStep?: (step: AgentStep) => void
|
onStep?: (step: AgentStep) => void
|
||||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
|
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
|
||||||
onToolCall?: (call: AgentToolCall) => void
|
onToolCall?: (call: AgentToolCall) => void
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codeassist-tauri"
|
name = "codeassist-tauri"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱"
|
description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱"
|
||||||
authors = ["justdodev"]
|
authors = ["justdodev"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeAssist",
|
"productName": "CodeAssist",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"identifier": "com.codeassist.app",
|
"identifier": "com.codeassist.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
|
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
|
||||||
|
|||||||
@@ -134,8 +134,12 @@ class TurnState:
|
|||||||
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
|
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
|
||||||
return out
|
return out
|
||||||
pid = props.get("partID", "")
|
pid = props.get("partID", "")
|
||||||
if self.part_types.get(pid) != "text":
|
ptype = self.part_types.get(pid)
|
||||||
return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
|
if ptype == "reasoning":
|
||||||
|
out.append(("step", {"id": pid, "kind": "reasoning", "text": props["delta"]}))
|
||||||
|
return out
|
||||||
|
if ptype != "text":
|
||||||
|
return out # tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
|
||||||
delta = props["delta"]
|
delta = props["delta"]
|
||||||
if pid not in self.text_by_part:
|
if pid not in self.text_by_part:
|
||||||
self.part_order.append(pid)
|
self.part_order.append(pid)
|
||||||
@@ -148,6 +152,11 @@ class TurnState:
|
|||||||
part = props.get("part") or {}
|
part = props.get("part") or {}
|
||||||
if part.get("id"):
|
if part.get("id"):
|
||||||
self.part_types[part["id"]] = part.get("type", "")
|
self.part_types[part["id"]] = part.get("type", "")
|
||||||
|
if part.get("type") == "tool":
|
||||||
|
# 도구 호출 진행 — 화면 "생각 과정" 에 이름·상태만. 입력/출력은 안 보냄(크고 사용자 관심 밖)
|
||||||
|
st = part.get("state") or {}
|
||||||
|
out.append(("step", {"id": part.get("id", ""), "kind": "tool", "tool": part.get("tool", ""), "status": st.get("status", ""), "title": st.get("title") or ""}))
|
||||||
|
return out
|
||||||
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
|
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
|
||||||
return out
|
return out
|
||||||
mid = part.get("messageID", "")
|
mid = part.get("messageID", "")
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def test_turnstate_snapshot_path_without_delta():
|
|||||||
def test_turnstate_ignores_reasoning_and_emits_title():
|
def test_turnstate_ignores_reasoning_and_emits_title():
|
||||||
st = TurnState("s1", "q")
|
st = TurnState("s1", "q")
|
||||||
st.handle(_msg_updated("ma", "assistant"))
|
st.handle(_msg_updated("ma", "assistant"))
|
||||||
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == []
|
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] # 스냅샷은 안 보냄(delta 만)
|
||||||
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
|
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
|
||||||
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
|
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
|
||||||
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
|
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
|
||||||
@@ -57,6 +57,14 @@ def test_turnstate_ignores_reasoning_and_emits_title():
|
|||||||
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
|
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
|
||||||
|
|
||||||
|
|
||||||
|
def test_turnstate_tool_part_becomes_step():
|
||||||
|
st = TurnState("s1", "q")
|
||||||
|
st.handle(_msg_updated("ma", "assistant"))
|
||||||
|
ev = {"type": "message.part.updated", "properties": {"part": {"id": "pt1", "messageID": "ma", "sessionID": "s1", "type": "tool", "tool": "sap-icf_get_program_source", "state": {"status": "running", "title": "ZRMEM2016", "input": {"x": 1}}}}}
|
||||||
|
assert st.handle(ev) == [("step", {"id": "pt1", "kind": "tool", "tool": "sap-icf_get_program_source", "status": "running", "title": "ZRMEM2016"})]
|
||||||
|
assert st.accumulated() == "" # 도구는 답변 본문에 안 섞임
|
||||||
|
|
||||||
|
|
||||||
def _delta(mid, pid, delta, sid="s1", field="text"):
|
def _delta(mid, pid, delta, sid="s1", field="text"):
|
||||||
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
|
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
|
||||||
|
|
||||||
@@ -68,7 +76,7 @@ def test_turnstate_real_server_shape_delta_events():
|
|||||||
st.handle(_part_updated("mu", "pu", "q"))
|
st.handle(_part_updated("mu", "pu", "q"))
|
||||||
st.handle(_msg_updated("ma", "assistant"))
|
st.handle(_msg_updated("ma", "assistant"))
|
||||||
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
|
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
|
||||||
assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄
|
assert st.handle(_delta("ma", "pr", "생각")) == [("step", {"id": "pr", "kind": "reasoning", "text": "생각"})]
|
||||||
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
|
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
|
||||||
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
|
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
|
||||||
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]
|
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]
|
||||||
|
|||||||
Reference in New Issue
Block a user