이벤트
하네스 어댑터가 샌드박스 내부에서 실행되면 해당 작업은 모두
chat() 스트림에서 관찰할 수 있습니다. 모든 chat() 실행이 생성하는 동일한
AG-UI StreamChunk와 샌드박스 및 하네스 전용 네임스페이스 CUSTOM 이벤트가
함께 전달됩니다.
이 페이지에서는 클라이언트가 읽는 스트림을 설명합니다. 파일 변경 시 서버 측 콜백을 실행하거나 샌드박스 내부를 로깅하려면 관찰 가능성을 참조하세요.
스트림
하네스 실행은 표준 AG-UI StreamChunk를 생성합니다.
- 텍스트: 어시스턴트의 증분 출력입니다.
- 도구 호출: 연결된 도구를 포함하며, 샌드박스 내부 에이전트가 호출하는 즉시 일반 도구 호출 청크로 표시됩니다.
- 추론: 하네스가 노출하는 경우 에이전트의 사고 과정입니다.
- 실행 수명 주기: 실행 시작, 실행 완료 및 관련 경계입니다.
사용자 지정 이벤트
표준 청크 외에도 샌드박스 및 하네스 계층은 각각 name과 value를 포함하는
CUSTOM 이벤트(chunk.type === 'CUSTOM')를 내보냅니다.
이벤트 name | 생성 주체 | 시점 | value |
|---|---|---|---|
grok-build.session-id | Grok Build 어댑터 | 샌드박스 내부 세션이 생성되거나 재개될 때 한 번 | 재개할 수 있는 하네스 세션 ID |
claude-code.session-id | Claude Code 어댑터 | 샌드박스 내부 세션이 생성되거나 재개될 때 한 번 | 재개할 수 있는 하네스 세션 ID |
codex.session-id | Codex 어댑터 | 세션이 생성되거나 재개될 때 한 번 | 재개할 수 있는 하네스 세션 ID |
opencode.session-id | OpenCode 어댑터 | 세션이 생성되거나 재개될 때 한 번 | 재개할 수 있는 하네스 세션 ID |
file.changed | 하네스 어댑터(예: Grok Build, Claude Code) | 실행 완료 후 | { path: string; diff: string }: 전체 작업 트리의 git diff입니다(path는 항상 트리 루트인 '.'입니다). |
sandbox.file | 엔진, 자동 생성 | 샌드박스가 활성 상태인 동안 파일을 생성·변경·삭제할 때마다 | { type: 'create' | 'change' | 'delete'; path: string; timestamp: number } |
sandbox.file.diff | fileEvents: { diff: true }로 선택적으로 활성화한 엔진 | 해당 sandbox.file 후 파일을 생성·변경·삭제할 때마다 | { path: string; diff: string }: 세션의 git 기준과 비교한 해당 파일 하나의 통합 패치입니다. |
*.session-id 이벤트를 사용하면 후속 실행에서 하네스 세션을 재개할 수 있습니다(어댑터의 modelOptions.sessionId를 통해 다시 전달합니다). sandbox.file은 샌드박스가 활성 상태이고 파일 감시가 켜져 있으면 훅 없이 자동으로 생성됩니다. sandbox.file.diff는 기본적으로 꺼져 있습니다(변경할 때마다 diff를 계산하는 데 비용이 들기 때문입니다). 클라이언트가 변경이 발생했다는 사실만 아는 것이 아니라 직접 렌더링해야 할 때는 defineSandbox에서 fileEvents: { diff: true }를 사용해 켤 수 있습니다:
import { defineSandbox } from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
const repoSandbox = defineSandbox({
id: "repo-agent",
provider: dockerSandbox({ image: "node:22" }),
fileEvents: { diff: true }, // also emit sandbox.file.diff per change
});
파일 변경을 훅을 통해 서버 측에서 처리하는 방법(동일한 diff와 before()/after()를 함께 받습니다)이나 감시 기능을 완전히 끄는 방법은 관찰 가능성을 참조하세요.
연결된 도구도 자체 이벤트를 생성합니다. 도구 브리지를 통해 실행되는
chat()도구는 실행 중간에CUSTOM이벤트를 다시 스트리밍할 수 있습니다. 예를 들어 코드 모드는code_mode:execution_started와code_mode:console(그리고code_mode:external_call/…_result/…_error)을 생성하므로 진행 상황을 실시간으로 표시할 수 있습니다. 아래와 같은 패턴으로 읽으세요.
클라이언트에서 CUSTOM 이벤트 읽기
TanStack AI 가 자체적으로 발생하는 모든 CUSTOM 이벤트, sandbox.file,
sandbox.file.diff, file.changed, *.session-id 이벤트 및 기타 이벤트는
고정된 name 와 구체적인 value 모양을 가지며, KnownCustomEvent 로 통합됩니다.
chat() 의 반환 타입은 이에 따라 좁아집니다: chunk.type === 'CUSTOM' 를 확인한 후
chunk.name 을 리터럴 문자열과 비교합니다. 별도의 헬퍼나 캐스팅 없이,
평범한 if 타입 chunk.value 을 제공합니다:
import { stream } from "./my-run";
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file") {
console.log(chunk.value.type, chunk.value.path); // typed, no cast
} else if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file.diff") {
console.log(chunk.value.path, chunk.value.diff); // typed, no cast
} else if (chunk.type === "CUSTOM" && chunk.name === "file.changed") {
console.log(chunk.value.diff); // typed, no cast
}
}
세션 ID 이벤트는 하나의 리터럴 이름이 아닙니다
*.session-id는 어댑터별로 생성되므로(claude-code.session-id, codex.session-id, grok-build.session-id, opencode.session-id) 타입은 하나의 문자열이 아니라 템플릿 리터럴 이름 `${string}.session-id`입니다. 실행 중인 어댑터를 알고 있다면 정확한 리터럴과 비교하세요. 그러면 다른 이벤트와 마찬가지로 chunk.value의 타입이 좁혀집니다:
import { resumeSession } from "./session";
import { stream } from "./my-run";
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "claude-code.session-id") {
resumeSession(chunk.value.sessionId); // typed as string, no cast
}
}
chunk.name.endsWith('.session-id')는 타입을 좁히지 않습니다. 이는 TypeScript가 타입에 연결할 수 없는 일반 불리언 표현식이므로chunk.value는 검사 전 타입(사실상unknown)을 그대로 유지합니다. 런타임에서 올바른 검사라도 마찬가지입니다. 모든 어댑터의 리터럴 이름을 나열하지 않고 어떤 어댑터의 세션 ID든 처리해야 한다면 대신 작은 타입 술어를 작성하세요:import type { KnownCustomEvent, SessionIdEvent } from "@tanstack/ai";
import { resumeSession } from "./session";
import { stream } from "./my-run";
function isSessionIdEvent(
chunk: KnownCustomEvent,
): chunk is SessionIdEvent {
return chunk.name.endsWith(".session-id");
}
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && isSessionIdEvent(chunk)) {
resumeSession(chunk.value.sessionId); // typed as string, no cast
}
}이 술어는 필요할 때 직접 작성하는 것입니다. TanStack AI는 가드 API를 제공하지 않습니다. 일반적인 리터럴-
name좁히기(위와 같음)가 기본적인 헬퍼 없는 패턴이며, "어떤 어댑터든" 처리해야 하는 경우에만 술어를 사용하세요.
타입이 지정된 전체 이벤트 분류, 이 좁히기에 사용되는 ChatStream 타입 및 직접 emitCustomEvent를 호출할 때의 트레이드오프는 사용자 지정 이벤트 참조를 확인하세요.
저장되는 항목
사용자가 다음 날 아침 스레드를 다시 열고 이전에 본 내용을 확인하려고 합니다. 에이전트가 실행한 명령과 그 결과입니다. 이 페이지의 스트림은 실시간 출력이며, 앱이 보관하는 대화 기록은 별개의 항목입니다.
채팅 영속성을 withSandbox와 함께 연결하면 완료된 실행은 메시지 저장소에 다음 내용을 남깁니다:
| 하네스가 생성한 항목 | 저장소의 내용 |
|---|---|
| 텍스트 | 예, 어시스턴트 메시지로 저장됩니다. |
| 도구 호출(이름 및 인수) | 예, 어시스턴트 메시지의 toolCalls로 저장됩니다. |
| 도구 결과 | 예, role: 'tool' 메시지로 저장됩니다. |
| 추론 | 아니요. |
CUSTOM 이벤트(파일 이벤트, 코드 모드 콘솔, 세션 ID) | 아니요. |
따라서 어떤 기기에서든 스레드를 다시 열면 결과가 포함된 도구 카드가 다시 구성됩니다.
CUSTOM 이벤트는 메시지가 아니므로, UI가 이벤트를 바탕으로 구성하는 파일 목록이나 콘솔 패널은 해당 방문 중에만 유지됩니다. 다시 표시하려면 그 상태를 직접 보관해야 합니다.
알아 두어야 할 제한은 두 가지입니다:
- 하네스의 모든 텍스트는 하나의 최종 어시스턴트 메시지로 도착합니다. 복원된 스레드에서는 도구 카드가 실행 순서대로 표시된 다음 전체 텍스트가 표시됩니다.
- 저장된 결과는 하네스가 보고한 문자열입니다. 스레드를 다시 열어도 도구가 다시 실행되지는 않습니다.
보관할 항목 줄이기
대화 기록을 저장할지 결정하려면 샌드박스 어댑터 빌드를 참조하세요. 이 섹션에서는 더 세밀하게 조정합니다. 대화 기록을 저장하되 더 작게 만들 수 있습니다.
한 번의 실행에서 도구 출력이 수백 KB에 이를 수 있으며, 결과는 전체가 저장소에 도달합니다. 무엇을 보관할지는 MessageStore가 결정합니다. isSandboxToolCall은 하네스가 샌드박스 내부에서 실행한 호출을 알려주므로, 해당 호출이 어떻게 표시되는지 알 필요가 없습니다. 렌더링된 tool-call 파트도 읽으므로 클라이언트 측 뷰를 필터링할 때 편리합니다.
각 결과에 상한을 설정하면 카드를 유지하면서 크기를 제한할 수 있습니다:
import type { ModelMessage } from "@tanstack/ai";
import type { MessageStore } from "@tanstack/ai-persistence";
import { db } from "./db";
const MAX_RESULT = 8_000;
const store: MessageStore = {
async saveThread(threadId, messages) {
const capped = messages.map((message: ModelMessage) =>
message.role === "tool" && typeof message.content === "string"
? { ...message, content: message.content.slice(0, MAX_RESULT) }
: message,
);
await db.saveThread(threadId, capped);
},
loadThread: (threadId) => db.loadThread(threadId),
};
대화 기록을 삭제하려면 각 결과를 해당 호출과 함께 제거하세요. 호출이 없는 결과는 프로바이더가 거부하므로 둘 중 어느 쪽보다 좋지 않습니다.
import { isSandboxToolCall } from "@tanstack/ai-sandbox";
import type { ModelMessage } from "@tanstack/ai";
import type { MessageStore } from "@tanstack/ai-persistence";
import { db } from "./db";
const store: MessageStore = {
async saveThread(threadId, messages) {
const dropped = new Set<string>();
const kept: Array<ModelMessage> = [];
for (const message of messages) {
const calls = message.toolCalls;
if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {
for (const call of calls) dropped.add(call.id);
continue;
}
if (
message.role === "tool" &&
message.toolCallId !== undefined &&
dropped.has(message.toolCallId)
) {
continue;
}
kept.push(message);
}
await db.saveThread(threadId, kept);
},
loadThread: (threadId) => db.loadThread(threadId),
};
이들을 삭제해도 안전합니다. 이들은 표시용 기록이며 여기서 재개되는 항목도 없습니다. 다음 턴의 모델 요청에도 전달되지 않는데, 프로바이더에 제공된 적이 없는 도구의 이름을 포함하기 때문입니다.
관련 항목
- 관찰 가능성, 서버 측 파일 이벤트 훅(
before()/after()/diff()), 디버그 로깅 및 저수준 감시 기능입니다. - 사용자 지정 이벤트 참조, 전체
KnownCustomEvent분류와ChatStream타입입니다. - 도구, 도구 호출(및 CUSTOM) 청크로 표시되는 연결된 호스트 도구입니다.
- 빠른 시작,
file.changeddiff를 처음부터 끝까지 읽는 방법입니다.