Skip to content

feat(session-contract): extract contract types and isCodesparSession guard #3

Description

@dangazineu

Goal

Move 13 contract-level types from packages/core/src/types.ts into the new packages/session-contract/src/types.ts. Add the isCodesparSession type guard in packages/session-contract/src/guards.ts. Update packages/core/src/types.ts to re-export from the contract package so all existing callers compile unchanged.

Context

The session contract types currently live inside packages/core. Any package that needs to reference Session, SessionBase, or StreamEvent must depend on @codespar/core even when it only needs the interface shapes — pulling in runtime code it doesn't need.

Extracting these types into a dedicated @codespar/session-contract package breaks that coupling. The contract package has no runtime dependencies: just TypeScript type definitions and a single type guard function. Packages that only need the interface shapes (SDK wrappers, adapters, test fixtures) can depend on the contract package directly without dragging in core internals.

This issue depends on the package scaffold being in place (ISSUE:1). The contract types established here are consumed by SDK wiring (ISSUE:3), the contract test suite (ISSUE:4), and the adapter layer (ISSUE:6).

Interface hierarchy

// packages/session-contract/src/types.ts

interface SessionBase {
readonly id: string;
readonly status: "active" | "closed" | "error";
execute(toolName: string, params: Record<string, unknown>): Promise<ToolResult>;
send(message: string): Promise<SendResult>;
sendStream(message: string): AsyncIterable<StreamEvent>;
connections(): Promise<BaseConnection[]>;
close(): Promise<void>;
}

interface Session extends SessionBase {
proxyExecute(request: ProxyRequest): Promise<ProxyResult>;
authorize(serverId: string, config: AuthConfig): Promise<AuthResult>;
mcp?: { url: string; headers: Record<string, string> };
}

type BaseConnection = { id: string; connected: boolean };

interface CreateSessionRequest {
servers: string[];
metadata?: Record<string, string>;
projectId?: string;
}

type StreamEvent =
| { type: "user_message"; content: string }
| { type: "assistant_text"; content: string; iteration: number }
| { type: "tool_use"; toolName: string; params: Record<string, unknown> }
| { type: "tool_result"; toolCall: ToolCallRecord }
| { type: "done" }
| { type: "error"; message: string; code?: string };

The remaining 8 types to move alongside the 5 above: ToolResult, SendResult, ServerConnection, ProxyRequest, ProxyResult, AuthConfig, AuthResult, ToolCallRecord.

Stability note — StreamEvent: SSE consumers in framework adapters and external callers read event.toolCall and event.iteration unconditionally. Do not rename, flatten, or reorder the union variants. ToolCallRecord mirrors the session_tool_calls database row shape — field renames here are a data-layer breaking change. Copy these types verbatim from packages/core/src/types.ts.

Type guard

// packages/session-contract/src/guards.ts
export function isCodesparSession(s: SessionBase): s is Session {
return "proxyExecute" in s && "authorize" in s;
}

Checks the two codespar-specific methods present on Session but absent from SessionBase. mcp is optional on Session and cannot serve as the discriminant — the two-method check is correct.

Main entrypoint

// packages/session-contract/src/index.ts
export * from "./types.js";
export * from "./guards.js";

Re-export in core

packages/core/src/types.ts must import the 13 types from @codespar/session-contract and re-export them so all existing import paths resolve unchanged:

export {
SessionBase,
Session,
BaseConnection,
CreateSessionRequest,
StreamEvent,
ToolResult,
SendResult,
ServerConnection,
ProxyRequest,
ProxyResult,
AuthConfig,
AuthResult,
ToolCallRecord,
} from "@codespar/session-contract";

Add "@codespar/session-contract": "*" to packages/core/package.json dependencies so this import resolves in the monorepo workspace. This workspace link is sufficient for this phase; the formal peer-dependency declaration is handled in ISSUE:3.

Acceptance Criteria

  • packages/session-contract/src/types.ts contains all 13 types listed above, copied verbatim from packages/core/src/types.ts.
  • packages/session-contract/src/guards.ts exports isCodesparSession exactly as specified.
  • packages/session-contract/src/index.ts re-exports from both files.
  • packages/core/src/types.ts no longer defines any of the 13 types locally — it imports and re-exports them all from @codespar/session-contract.
  • packages/core/package.json lists "@codespar/session-contract": "*" in dependencies.
  • pnpm --filter @codespar/session-contract build completes without errors.
  • pnpm --filter @codespar/core typecheck completes without errors.
  • pnpm --filter @codespar/sdk typecheck completes without errors (no import path changes required in the SDK).
  • isCodesparSession correctly narrows SessionBase to Session when both proxyExecute and authorize are present, and does not narrow when either is absent.
  • No changes to any file outside packages/session-contract/ and packages/core/src/types.ts and packages/core/package.json.

Validation

#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"

echo "=== Build: @codespar/session-contract ==="
pnpm --filter @codespar/session-contract build

echo "=== Typecheck: @codespar/core ==="
pnpm --filter @codespar/core typecheck

echo "=== Typecheck: @codespar/sdk ==="
pnpm --filter @codespar/sdk typecheck

echo "=== Type guard narrowing check ==="
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

cat > "$TMPDIR/guard-check.ts" <<'EOF'
import { SessionBase, Session, isCodesparSession } from "@codespar/session-contract";

// Minimal object that satisfies Session structurally
const full = {
id: "s1",
status: "active" as const,
execute: async () => ({ success: true as const, output: "" }),
send: async () => ({ success: true as const }),
sendStream: async function* () {},
connections: async () => [],
close: async () => {},
proxyExecute: async () => ({ success: true as const, output: "" }),
authorize: async () => ({ success: true as const, token: "" }),
};

// Minimal object that satisfies only SessionBase
const base = {
id: "s2",
status: "active" as const,
execute: async () => ({ success: true as const, output: "" }),
send: async () => ({ success: true as const }),
sendStream: async function* () {},
connections: async () => [],
close: async () => {},
};

// isCodesparSession must narrow full to Session
function assertSession(s: Session): void {}

if (isCodesparSession(full as SessionBase)) {
assertSession(full);
}

// isCodesparSession must not narrow base (it lacks both methods)
if (isCodesparSession(base)) {
// This branch should not be reached, but if the type compiles it's a TS error
// @ts-expect-error base does not satisfy Session
assertSession(base);
}

export {};
EOF

cat > "$TMPDIR/tsconfig.json" <<'EOF'
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"module": "esnext",
"target": "esnext",
"noEmit": true,
"paths": {
"@codespar/session-contract": ["../packages/session-contract/src/index.ts"]
},
"baseUrl": "$REPO_ROOT"
},
"include": ["guard-check.ts"]
}
EOF

# Replace placeholder in tsconfig
sed -i "s|\$REPO_ROOT|$REPO_ROOT|g" "$TMPDIR/tsconfig.json"

pnpm exec tsc --project "$TMPDIR/tsconfig.json"

echo "=== Verify core types.ts no longer defines contract types locally ==="
for TYPE in SessionBase Session BaseConnection CreateSessionRequest StreamEvent \
ToolResult SendResult ServerConnection ProxyRequest ProxyResult \
AuthConfig AuthResult ToolCallRecord; do
if grep -Pn "^(export\s+)?(interface|type)\s+${TYPE}\b" \
"$REPO_ROOT/packages/core/src/types.ts" > /dev/null 2>&1; then
echo "FAIL: $TYPE is still defined locally in packages/core/src/types.ts"
exit 1
fi
done
echo "All 13 types confirmed absent from core/src/types.ts local definitions."

echo "=== All checks passed ==="

Dependencies

  • ISSUE:1 — the packages/session-contract package scaffold must exist (directory, package.json, tsconfig.json, build script) before types can be placed there.

Downstream Dependencies

  • ISSUE:3 — SDK wiring imports Session and SessionBase from @codespar/session-contract directly; the interface hierarchy established here defines the SDK surface.
  • ISSUE:4 — contract test suite imports from @codespar/session-contract to validate the type guard and interface shapes against live session objects.
  • ISSUE:6 — adapter layer implements SessionBase and uses isCodesparSession to determine whether to call proxyExecute; both must be stable before the adapter can be written.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    f4-m1Feature 4, Milestone 1: Shared Session Contract Packagevalidation:testableTestable validation: includes validation script

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions